Skip to content

Commit

Permalink
feat: text attachments in short answer XBlock
Browse files Browse the repository at this point in the history
  • Loading branch information
ArturGaspar committed Nov 11, 2024
1 parent 9fa5d57 commit 3df46aa
Show file tree
Hide file tree
Showing 3 changed files with 199 additions and 6 deletions.
88 changes: 82 additions & 6 deletions ai_eval/shortanswer.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
"""Short answers Xblock with AI evaluation."""

import email
import json
import logging
import traceback

from xml.sax import saxutils

from django.utils.translation import gettext_noop as _
from web_fragments.fragment import Fragment
from webob import Response
from webob.exc import HTTPForbidden, HTTPNotFound
from xblock.core import XBlock
from xblock.exceptions import JsonHandlerError
from xblock.fields import Boolean, Integer, String, Scope
from xblock.fields import Boolean, Dict, Integer, String, Scope
from xblock.validation import ValidationMessage

from .llm import get_llm_response
Expand All @@ -18,6 +22,7 @@
logger = logging.getLogger(__name__)


@XBlock.wants('studio_user_permissions')
class ShortAnswerAIEvalXBlock(AIEvalXBlock):
"""
Short Answer Xblock.
Expand All @@ -44,9 +49,17 @@ class ShortAnswerAIEvalXBlock(AIEvalXBlock):
default=False,
)

attachments = Dict(
display_name=_("Attachments"),
help=_("Attachments to include with the evaluation prompt"),
scope=Scope.settings,
resettable_editor=False,
)

editable_fields = AIEvalXBlock.editable_fields + (
"max_responses",
"allow_reset",
"attachments",
)

def validate_field_data(self, validation, data):
Expand Down Expand Up @@ -99,15 +112,28 @@ def student_view(self, context=None):
def get_response(self, data, suffix=""): # pylint: disable=unused-argument
"""Get LLM feedback"""
user_submission = str(data["user_input"])

attachments = []
for filename, contents in self.attachments.items():
attachments.append(f"""
<attachment>
<filename>{saxutils.escape(filename)}</filename>
<contents>{saxutils.escape(contents)}</contents>
</attachment>
""")
attachments = '\n'.join(attachments)

system_msg = {
"role": "system",
"content": f"""
{self.evaluation_prompt}
{self.evaluation_prompt}
{self.question}.
{attachments}
Evaluation must be in Makrdown format.
""",
{self.question}.
Evaluation must be in Markdown format.
""",
}
messages = [system_msg]
# add previous messages
Expand Down Expand Up @@ -152,6 +178,56 @@ def reset(self, data, suffix=""):
self.messages = {self.USER_KEY: [], self.LLM_KEY: []}
return {}

def studio_view(self, context):
"""
Render a form for editing this XBlock
"""
fragment = super().studio_view(context)
fragment.add_javascript(self.resource_string("static/js/src/shortanswer_edit.js"))
# ShortAnswerAIEvalXBlock() in base.js will call StudioEditableXBlockMixin().
fragment.initialize_js("ShortAnswerAIEvalXBlock")
return fragment

# Optimisation: don't send file contents to the edit view,
# and use null value as flag to keep same contents.

def _make_field_info(self, field_name, field):
info = super()._make_field_info(field_name, field)
if field_name == "attachments":
info["value"] = json.dumps(
{f: None for f in field.read_from(self).keys()}
)
return info

@XBlock.json_handler
def submit_studio_edits(self, data, suffix=''):
if "attachments" in data["values"]:
for key, value in list(data["values"]["attachments"].items()):
if value is None:
data["values"]["attachments"][key] = self.attachments[key]
return super().submit_studio_edits.__wrapped__(self, data, suffix)

@XBlock.handler
def view_attachment(self, request, suffix=''):
user_perms = self.runtime.service(self, 'studio_user_permissions')
if not (user_perms and user_perms.can_read(self.scope_ids.usage_id.context_key)):
return request.get_response(HTTPForbidden())

key = request.GET['key']
try:
data = self.attachments[key]
except KeyError:
return request.get_response(HTTPNotFound())

escaped = key.replace("\\", "\\\\").replace('"', '\\"')
return Response(
body=data.encode(),
headerlist=[
("Content-Type", "application/octet-stream"),
("Content-Disposition", f'attachment; filename="{escaped}"'),
]
)

@staticmethod
def workbench_scenarios():
"""A canned scenario for display in the workbench."""
Expand Down
101 changes: 101 additions & 0 deletions ai_eval/static/js/src/shortanswer_edit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/* Javascript for ShortAnswerAIEvalXBlock. */
function ShortAnswerAIEvalXBlock(runtime, element) {
"use strict";

StudioEditableXBlockMixin(runtime, element);

var viewAttachmentUrl = runtime.handlerUrl(element, "view_attachment");

var $input = $('#xb-field-edit-attachments');

var buildFileInput = function() {
var $wrapper = $('<div id="attachments-wrapper" class="wrapper-list-settings"/>');

var $fileList = $('<ul class="list-settings list-set"/>');
var files = JSON.parse($input.val() || "{}");
for (var filename of Object.keys(files)) {
var $fileItem = $('<li class="list-settings-item"/>');
var $fileLink;
if (files[filename] === null) {
/* File that already exists. */
$fileLink = $('<a/>');
$fileLink.attr("target", "_blank");
var fileLinkQuery = new URLSearchParams({key: filename}).toString();
$fileLink.attr('href', `${viewAttachmentUrl}?${fileLinkQuery}`);
} else {
$fileLink = $('<span/>');
}
$fileLink.append(filename);
$fileItem.append($fileLink);
var $deleteButton = $('<button class="action" type="button"/>');
$deleteButton.append($('<i class="icon fa fa-trash"/>'));
var $deleteButtonText = $('<span class="sr"/>');
$deleteButtonText.append(gettext("Delete file"));
$deleteButton.append($deleteButtonText);
$deleteButton.data("attachment-name", filename);
$deleteButton.click(deleteAttachment);
$fileItem.append($deleteButton);
$fileList.append($fileItem);
}
$wrapper.append($fileList);

var $fileInput = $('<input type="file"/>');
$wrapper.append($fileInput);

var $uploadButton = $('<button class="action" type="button"/>');
$uploadButton.append($('<i class="icon fa fa-upload"/>'));
$uploadButton.click(addAttachment);
var $uploadButtonText = $('<span class="sr"/>');
$uploadButtonText.append(gettext("Upload file"));
$uploadButton.append($uploadButtonText);
$wrapper.append($uploadButton);

var $oldWrapper = $('#attachments-wrapper');
if ($oldWrapper.length) {
$input.closest('li').addClass('is-set');
$oldWrapper.replaceWith($wrapper);
} else {
$input.hide();
$wrapper.insertBefore($input);
}
}

var deleteAttachment = function() {
var $button = $(this);

var files = JSON.parse($input.val());
delete files[$button.data("attachment-name")];
$input.val(JSON.stringify(files));

buildFileInput();
}

var insertAttachment = function(files, filename, data) {
var foundFilename = filename;
var i = 0;
while (Object.prototype.hasOwnProperty.call(files, foundFilename)) {
i++;
foundFilename = `${filename} (${i})`;
}
files[foundFilename] = data;
}

var addAttachment = function() {
var $button = $(this);
var $fileInput = $button.prev('input[type="file"]');
var file = $fileInput[0].files[0];

if (file !== undefined) {
var reader = new FileReader();
reader.onload = function(e) {
var files = JSON.parse($input.val());
insertAttachment(files, file.name, e.target.result);
$input.val(JSON.stringify(files));
buildFileInput();
}
reader.readAsText(file);
}
}

buildFileInput();
}
16 changes: 16 additions & 0 deletions ai_eval/tests/test_ai_eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

import unittest
from unittest.mock import patch
from xblock.exceptions import JsonHandlerError
from xblock.field_data import DictFieldData
from xblock.test.toy_runtime import ToyRuntime
Expand Down Expand Up @@ -85,3 +86,18 @@ def test_reset_forbidden(self):
with self.assertRaises(JsonHandlerError):
block.reset.__wrapped__(block, data={})
self.assertEqual(block.messages, {"USER": ["Hello"], "LLM": ["Hello"]})

@patch('ai_eval.shortanswer.get_llm_response')
def test_attachments(self, get_llm_response):
"""Test the attachments."""
data = {
**self.data,
"attachments": {"test.json": '{"test": "test"}'},
}
block = ShortAnswerAIEvalXBlock(ToyRuntime(), DictFieldData(data), None)
get_llm_response.return_value = "Hello"
block.get_response.__wrapped__(block, data={"user_input": "Hello"})
system_msg = get_llm_response.call_args.args[2][0]["content"]
self.assertIn("<filename>test.json</filename>", system_msg)
self.assertIn('<contents>{"test": "test"}</contents>', system_msg)
self.assertEqual(block.messages, {"USER": ["Hello"], "LLM": ["Hello"]})

0 comments on commit 3df46aa

Please sign in to comment.