Skip to content

Commit e82ae80

Browse files
committed
feat: AI XBlock conversation history export
1 parent 30e6c2b commit e82ae80

File tree

14 files changed

+705
-120
lines changed

14 files changed

+705
-120
lines changed

ai_eval/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55
from .shortanswer import ShortAnswerAIEvalXBlock
66
from .coding_ai_eval import CodingAIEvalXBlock
77
from .multiagent import MultiAgentAIEvalXBlock
8+
from .export import DataExportXBlock

ai_eval/export.py

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
# noqa
2+
# pylint: skip-file
3+
4+
#
5+
# Copyright (c) 2014-2015 Harvard, edX & OpenCraft
6+
#
7+
# This software's license gives you freedom; you can copy, convey,
8+
# propagate, redistribute and/or modify this program under the terms of
9+
# the GNU Affero General Public License (AGPL) as published by the Free
10+
# Software Foundation (FSF), either version 3 of the License, or (at your
11+
# option) any later version of the AGPL published by the FSF.
12+
#
13+
# This program is distributed in the hope that it will be useful, but
14+
# WITHOUT ANY WARRANTY; without even the implied warranty of
15+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
16+
# General Public License for more details.
17+
#
18+
# You should have received a copy of the GNU Affero General Public License
19+
# along with this program in a file in the toplevel directory called
20+
# "AGPLv3". If not, see <http://www.gnu.org/licenses/>.
21+
#
22+
"""
23+
Instructor Tool: An XBlock for instructors to export student answers from a course.
24+
25+
All processing is done offline.
26+
"""
27+
import json
28+
import pkg_resources
29+
30+
from django.utils.translation import gettext_noop as _
31+
from web_fragments.fragment import Fragment
32+
from xblock.core import XBlock
33+
from xblock.exceptions import JsonHandlerError
34+
from xblock.fields import Dict, Scope, String
35+
from xblock.utils.resources import ResourceLoader
36+
37+
loader = ResourceLoader(__name__)
38+
39+
40+
@XBlock.wants('user')
41+
class DataExportXBlock(XBlock):
42+
icon_class = "problem"
43+
display_name = String(
44+
display_name=_("(Display name)"),
45+
help=_("Title to display"),
46+
default=_("Data export"),
47+
scope=Scope.settings
48+
)
49+
active_export_task_id = String(
50+
# The UUID of the celery AsyncResult for the most recent export,
51+
# IF we are sill waiting for it to finish
52+
default="",
53+
scope=Scope.user_state,
54+
)
55+
last_export_result = Dict(
56+
# The info dict returned by the most recent successful export.
57+
# If the export failed, it will have an "error" key set.
58+
default=None,
59+
scope=Scope.user_state,
60+
)
61+
62+
has_author_view = True
63+
64+
def author_view(self, context=None):
65+
""" Studio View """
66+
# Warn the user that this block will only work from the LMS. (Since the CMS uses
67+
# different celery queues; our task listener is waiting for tasks on the LMS queue)
68+
return Fragment('<p>Data Export Block</p><p>This block only works from the LMS.</p>')
69+
70+
def studio_view(self, context=None):
71+
""" View for editing Instructor Tool block in Studio. """
72+
# Display friendly message explaining that the block is not editable.
73+
return Fragment('<p>This is a preconfigured block. It is not editable.</p>')
74+
75+
def check_pending_export(self):
76+
"""
77+
If we're waiting for an export, see if it has finished, and if so, get the result.
78+
"""
79+
from .tasks import export_data as export_data_task
80+
if self.active_export_task_id:
81+
async_result = export_data_task.AsyncResult(self.active_export_task_id)
82+
if async_result.ready():
83+
self._save_result(async_result)
84+
85+
def _save_result(self, task_result):
86+
""" Given an AsyncResult or EagerResult, save it. """
87+
self.active_export_task_id = ''
88+
if task_result.successful():
89+
self.last_export_result = task_result.result
90+
else:
91+
self.last_export_result = {'error': str(task_result.result)}
92+
93+
def resource_string(self, path):
94+
"""Handy helper for getting resources from our kit."""
95+
data = pkg_resources.resource_string(__name__, path)
96+
return data.decode("utf8")
97+
98+
def student_view(self, context=None):
99+
""" Normal View """
100+
if not self.user_is_staff():
101+
return Fragment('<p>This interface can only be used by course staff.</p>')
102+
103+
html = loader.render_django_template('templates/export.html')
104+
frag = Fragment(html)
105+
frag.add_javascript(self.resource_string("static/js/src/export.js"))
106+
frag.initialize_js('DataExportXBlock')
107+
return frag
108+
109+
@property
110+
def download_url_for_last_report(self):
111+
""" Get the URL for the last report, if any """
112+
# Unfortunately this is a bit inefficient due to the ReportStore API
113+
if not self.last_export_result or 'error' in self.last_export_result:
114+
return None
115+
from lms.djangoapps.instructor_task.models import ReportStore
116+
report_store = ReportStore.from_config(config_name='GRADES_DOWNLOAD')
117+
course_key = getattr(self.scope_ids.usage_id, 'course_key', None)
118+
return dict(report_store.links_for(course_key)).get(self.last_export_result['report_filename'])
119+
120+
def _get_status(self):
121+
self.check_pending_export()
122+
return {
123+
'export_pending': bool(self.active_export_task_id),
124+
'last_export_result': self.last_export_result,
125+
'download_url': self.download_url_for_last_report,
126+
}
127+
128+
def raise_error(self, code, message):
129+
"""
130+
Raises an error and marks the block with a simulated failed task dict.
131+
"""
132+
self.last_export_result = {
133+
'error': message,
134+
}
135+
self.display_data = None
136+
raise JsonHandlerError(code, message)
137+
138+
@XBlock.json_handler
139+
def get_status(self, data, suffix=''):
140+
return self._get_status()
141+
142+
@XBlock.json_handler
143+
def delete_export(self, data, suffix=''):
144+
self._delete_export()
145+
return self._get_status()
146+
147+
def _delete_export(self):
148+
self.last_export_result = None
149+
self.display_data = None
150+
self.active_export_task_id = ''
151+
152+
@XBlock.json_handler
153+
def start_export(self, data, suffix=''):
154+
""" Start a new asynchronous export """
155+
if not self.user_is_staff():
156+
raise JsonHandlerError(403, "Permission denied.")
157+
158+
# Launch task
159+
from .tasks import export_data as export_data_task
160+
self._delete_export()
161+
# Make sure we nail down our state before sending off an asynchronous task.
162+
self.save()
163+
async_result = export_data_task.delay(
164+
# course_id not available in workbench.
165+
str(getattr(self.runtime, 'course_id', 'course_id')),
166+
)
167+
if async_result.ready():
168+
# In development mode, the task may have executed synchronously.
169+
# Store the result now, because we won't be able to retrieve it later :-/
170+
if async_result.successful():
171+
# Make sure the result can be represented as JSON, since the non-eager celery
172+
# requires that
173+
json.dumps(async_result.result)
174+
self._save_result(async_result)
175+
else:
176+
# The task is running asynchronously. Store the result ID so we can query its progress:
177+
self.active_export_task_id = async_result.id
178+
return self._get_status()
179+
180+
@XBlock.json_handler
181+
def cancel_export(self, request, suffix=''):
182+
from .tasks import export_data as export_data_task
183+
if self.active_export_task_id:
184+
async_result = export_data_task.AsyncResult(self.active_export_task_id)
185+
async_result.revoke()
186+
self._delete_export()
187+
188+
def _get_user_attr(self, attr):
189+
"""Get an attribute of the current user."""
190+
user_service = self.runtime.service(self, 'user')
191+
if user_service:
192+
# May be None when creating bok choy test fixtures
193+
return user_service.get_current_user().opt_attrs.get(attr)
194+
return None
195+
196+
def user_is_staff(self):
197+
"""Return a Boolean value indicating whether the current user is a member of staff."""
198+
return self._get_user_attr('edx-platform.user_is_staff')

ai_eval/static/js/src/export.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
function DataExportXBlock(runtime, element, data) {
2+
"use strict";
3+
4+
var status;
5+
6+
const getStatusUrl = runtime.handlerUrl(element, "get_status");
7+
const startExportUrl = runtime.handlerUrl(element, "start_export");
8+
9+
const $results = $("#results-wrapper", element);
10+
const $refresh = $(".data-export-refresh", $results);
11+
const $status = $(".data-export-status", $results);
12+
const $download = $(".data-export-download", $results);
13+
const $start = $(".data-export-start", $results);
14+
15+
const updateStatus = function(newStatus) {
16+
status = newStatus;
17+
if (newStatus.export_pending) {
18+
$start.hide();
19+
$download.hide();
20+
$download.removeAttr("href");
21+
$status.text("Running...");
22+
} else if (newStatus.download_url !== null) {
23+
$start.show();
24+
$download.attr("href", newStatus.download_url);
25+
$download.show();
26+
$status.text("Ready.");
27+
} else if (newStatus.last_export_result.error) {
28+
$start.show();
29+
$download.hide();
30+
$download.removeAttr("href");
31+
$status.text("Error.");
32+
} else {
33+
$start.show();
34+
$download.hide();
35+
$download.removeAttr("href");
36+
$status.text("Idle.");
37+
}
38+
}
39+
40+
const getStatus = function() {
41+
$status.text("...");
42+
$.ajax({
43+
type: 'POST',
44+
url: getStatusUrl,
45+
data: JSON.stringify({}),
46+
success: updateStatus,
47+
error: function() {
48+
alert(gettext("An error has occurred."));
49+
},
50+
});
51+
}
52+
$refresh.on("click", getStatus);
53+
54+
$start.on('click', function() {
55+
$status.text("...");
56+
$.ajax({
57+
type: 'POST',
58+
url: startExportUrl,
59+
data: JSON.stringify({}),
60+
success: updateStatus,
61+
error: function() {
62+
alert(gettext("An error has occurred."));
63+
},
64+
});
65+
});
66+
67+
getStatus();
68+
}

ai_eval/tasks.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""
2+
Celery task for student messages export.
3+
"""
4+
import itertools
5+
import time
6+
7+
from celery import shared_task
8+
from celery.utils.log import get_task_logger
9+
from django.contrib.auth import get_user_model
10+
from xblock.fields import Scope
11+
12+
from . import ShortAnswerAIEvalXBlock
13+
14+
logger = get_task_logger(__name__)
15+
16+
User = get_user_model()
17+
18+
19+
@shared_task()
20+
def export_data(course_id_str):
21+
"""
22+
Exports chat logs from all supported XBlocks.
23+
"""
24+
# pylint: disable=import-error,import-outside-toplevel
25+
from common.djangoapps.util.file import course_filename_prefix_generator
26+
from lms.djangoapps.instructor_task.models import ReportStore
27+
from opaque_keys.edx.keys import CourseKey
28+
29+
start_timestamp = time.time()
30+
31+
course_id = CourseKey.from_string(course_id_str)
32+
33+
logger.debug("Beginning data export")
34+
35+
header = ("Section", "Subsection", "Unit", "Location",
36+
"Display Name", "Username", "User E-mail", "Conversation",
37+
"Source", "Message")
38+
39+
rows = itertools.chain([header], _extract_all_data(course_id))
40+
41+
report_store = ReportStore.from_config(config_name='GRADES_DOWNLOAD')
42+
43+
timestamp = time.strftime("%Y-%m-%d-%H%M%S", time.gmtime(start_timestamp))
44+
filename = "ai_eval_history-{course_prefix}-{timestamp_str}.csv".format(
45+
course_prefix=course_filename_prefix_generator(course_id),
46+
timestamp_str=timestamp
47+
)
48+
report_store.store_rows(course_id, filename, rows)
49+
50+
generation_time_s = time.time() - start_timestamp
51+
logger.debug(f"Done data export - took {generation_time_s} seconds")
52+
53+
return {
54+
"report_filename": filename,
55+
"start_timestamp": start_timestamp,
56+
"generation_time_s": generation_time_s,
57+
}
58+
59+
60+
def _extract_all_data(course_id):
61+
"""Extract data for all XBlocks supported in this package."""
62+
# pylint: disable=import-error,import-outside-toplevel
63+
from xmodule.modulestore.django import modulestore
64+
65+
store = modulestore()
66+
for block in store.get_items(course_id):
67+
if isinstance(block, ShortAnswerAIEvalXBlock):
68+
yield from _extract_data(block)
69+
70+
71+
def _extract_data(block):
72+
"""Extract data for one XBlock."""
73+
# pylint: disable=import-error,import-outside-toplevel
74+
from common.djangoapps.student.models import CourseEnrollment
75+
from lms.djangoapps.courseware.model_data import (
76+
DjangoKeyValueStore,
77+
FieldDataCache,
78+
)
79+
80+
section_name, subsection_name, unit_name = _get_context(block)
81+
82+
for user in CourseEnrollment.objects.users_enrolled_in(str(block.course_id)):
83+
data = FieldDataCache([], block.course_id, user)
84+
data.add_blocks_to_cache([block])
85+
86+
try:
87+
messages = data.get(DjangoKeyValueStore.Key(
88+
scope=Scope.user_state,
89+
user_id=user.id,
90+
block_scope_id=block.location,
91+
field_name='messages'
92+
))
93+
except KeyError:
94+
continue
95+
96+
conversation = 1
97+
for user_message, llm_message in zip(messages['USER'], messages['LLM']):
98+
yield (section_name, subsection_name, unit_name,
99+
str(block.location), block.display_name,
100+
user.username, user.email or "", conversation,
101+
"user", user_message)
102+
yield (section_name, subsection_name, unit_name,
103+
str(block.location), block.display_name,
104+
user.username, user.email or "", conversation,
105+
"llm", llm_message)
106+
107+
108+
def _get_context(block):
109+
"""
110+
Return section, subsection, and unit names for `block`.
111+
"""
112+
block_names_by_type = {}
113+
block_iter = block
114+
while block_iter:
115+
block_iter_type = block_iter.scope_ids.block_type
116+
block_names_by_type[block_iter_type] = block_iter.display_name_with_default
117+
block_iter = block_iter.get_parent() if block_iter.parent else None
118+
section_name = block_names_by_type.get('chapter', '')
119+
subsection_name = block_names_by_type.get('sequential', '')
120+
unit_name = block_names_by_type.get('vertical', '')
121+
return section_name, subsection_name, unit_name

0 commit comments

Comments
 (0)