xblock-ai-eval 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ai_eval/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """
2
+ Xblock to have short text and code entries with AI-driven evaluation.
3
+ """
4
+
5
+ from .shortanswer import ShortAnswerAIEvalXBlock
6
+ from .coding_ai_eval import CodingAIEvalXBlock
ai_eval/base.py ADDED
@@ -0,0 +1,193 @@
1
+ """Base Xblock with AI evaluation."""
2
+ from typing import Self
3
+
4
+ import pkg_resources
5
+
6
+ from django.utils.translation import gettext_noop as _
7
+ from xblock.core import XBlock
8
+ from xblock.fields import String, Scope, Dict
9
+ from xblock.utils.resources import ResourceLoader
10
+ from xblock.utils.studio_editable import StudioEditableXBlockMixin
11
+ from xblock.validation import ValidationMessage
12
+
13
+ from .compat import get_site_configuration_value
14
+ from .llm import SupportedModels
15
+
16
+
17
+ @XBlock.wants("settings")
18
+ class AIEvalXBlock(StudioEditableXBlockMixin, XBlock):
19
+ """
20
+ Base class for Xblocks with AI evaluation
21
+ """
22
+
23
+ USER_KEY = "USER"
24
+ LLM_KEY = "LLM"
25
+
26
+ loader = ResourceLoader(__name__)
27
+
28
+ icon_class = "problem"
29
+ model_api_key = String(
30
+ display_name=_("Chosen model API Key"),
31
+ help=_("Enter the API Key of your chosen model. Not required if your administrator has set it globally."),
32
+ default="",
33
+ scope=Scope.settings,
34
+ )
35
+ model_api_url = String(
36
+ display_name=_("Set your API URL"),
37
+ help=_(
38
+ "Fill this only for LLama. This required with models that don't have an official provider."
39
+ " Example URL: https://model-provider-example/llama3_70b"
40
+ ),
41
+ default=None,
42
+ scope=Scope.settings,
43
+ )
44
+ model = String(
45
+ display_name=_("AI model"),
46
+ help=_("Select the AI language model to use."),
47
+ values=[
48
+ {"display_name": model, "value": model} for model in SupportedModels.list()
49
+ ],
50
+ Scope=Scope.settings,
51
+ default=SupportedModels.GPT4O.value,
52
+ )
53
+
54
+ evaluation_prompt = String(
55
+ display_name=_("Evaluation prompt"),
56
+ help=_(
57
+ "Enter the evaluation prompt given to the model."
58
+ " The question will be inserted right after it."
59
+ " The student's answer would then follow the question. Markdown format can be used."
60
+ ),
61
+ default="You are a teacher. Evaluate the student's answer for the following question:",
62
+ multiline_editor=True,
63
+ scope=Scope.settings,
64
+ )
65
+ question = String(
66
+ display_name=_("Question"),
67
+ help=_(
68
+ "Enter the question you would like the students to answer."
69
+ " Markdown format can be used."
70
+ ),
71
+ default="",
72
+ multiline_editor=True,
73
+ scope=Scope.settings,
74
+ )
75
+
76
+ messages = Dict(
77
+ help=_("Dictionary with chat messages"),
78
+ scope=Scope.user_state,
79
+ default={USER_KEY: [], LLM_KEY: []},
80
+ )
81
+ editable_fields = (
82
+ "display_name",
83
+ "evaluation_prompt",
84
+ "question",
85
+ "model",
86
+ "model_api_key",
87
+ "model_api_url",
88
+ )
89
+
90
+ block_settings_key = "ai_eval"
91
+
92
+ def _get_settings(self) -> dict: # pragma: nocover
93
+ """Get the XBlock settings bucket via the SettingsService."""
94
+ settings_service = self.runtime.service(self, "settings")
95
+ if settings_service:
96
+ return settings_service.get_settings_bucket(self)
97
+
98
+ return {}
99
+
100
+ def resource_string(self, path):
101
+ """Handy helper for getting resources from our kit."""
102
+ data = pkg_resources.resource_string(__name__, path)
103
+ return data.decode("utf8")
104
+
105
+ def _get_model_config_value(self, config_parameter: str, obj: Self = None) -> str | None:
106
+ """
107
+ Get configuration value for the model provider with a fallback chain.
108
+
109
+ Checks for the value in the following order:
110
+ 1. XBlock field (model_api_key or model_api_url)
111
+ 2. Site configuration
112
+ 3. XBlock settings (defined in Django settings)
113
+
114
+ Args:
115
+ config_parameter: Parameter to retrieve (e.g., "API_KEY" or "API_URL").
116
+ obj: Optional data object for validation context.
117
+
118
+ Returns:
119
+ The configuration value if found in any of the sources, None otherwise.
120
+ """
121
+ obj = obj or self
122
+ field_name = f"model_{config_parameter}"
123
+ config_key = f"{SupportedModels(obj.model).name}_{config_parameter.upper()}"
124
+
125
+ # XBlock field
126
+ if value := getattr(obj, field_name, None):
127
+ return str(value)
128
+
129
+ # Site configuration
130
+ if value := get_site_configuration_value(self.block_settings_key, config_key):
131
+ return value
132
+
133
+ # XBlock settings
134
+ return self._get_settings().get(config_key)
135
+
136
+ def get_model_api_key(self, obj: Self = None) -> str | None:
137
+ """Get the API key for the model provider."""
138
+
139
+ return self._get_model_config_value("api_key", obj)
140
+
141
+ def get_model_api_url(self, obj: Self = None) -> str | None:
142
+ """
143
+ Get the API URL for the model provider.
144
+ """
145
+ return self._get_model_config_value("api_url", obj)
146
+
147
+ def validate_field_data(self, validation, data):
148
+ """
149
+ Validate fields.
150
+ """
151
+
152
+ if not data.model or data.model not in SupportedModels.list():
153
+ validation.add(
154
+ ValidationMessage(
155
+ ValidationMessage.ERROR,
156
+ _( # pylint: disable=translation-of-non-string
157
+ f"Model field is mandatory and must be one of {', '.join(SupportedModels.list())}"
158
+ ),
159
+ )
160
+ )
161
+
162
+ if not self.get_model_api_key(data):
163
+ validation.add(
164
+ ValidationMessage(
165
+ ValidationMessage.ERROR, _("Model API key is mandatory, if not set globally by your administrator.")
166
+ )
167
+ )
168
+
169
+ if data.model == SupportedModels.LLAMA.value and not self.get_model_api_url(data):
170
+ validation.add(
171
+ ValidationMessage(
172
+ ValidationMessage.ERROR,
173
+ _(
174
+ "API URL field is mandatory when using ollama/llama2, "
175
+ "if not set globally by your administrator."
176
+ ),
177
+ )
178
+ )
179
+
180
+ if data.model != SupportedModels.LLAMA.value and data.model_api_url:
181
+ validation.add(
182
+ ValidationMessage(
183
+ ValidationMessage.ERROR,
184
+ _("API URL field can be set only when using ollama/llama2."),
185
+ )
186
+ )
187
+
188
+ if not data.question:
189
+ validation.add(
190
+ ValidationMessage(
191
+ ValidationMessage.ERROR, _("Question field is mandatory")
192
+ )
193
+ )
@@ -0,0 +1,259 @@
1
+ """Coding Xblock with AI evaluation."""
2
+
3
+ import logging
4
+ import traceback
5
+ import pkg_resources
6
+
7
+
8
+ from django.utils.translation import gettext_noop as _
9
+ from web_fragments.fragment import Fragment
10
+ from xblock.core import XBlock
11
+ from xblock.exceptions import JsonHandlerError
12
+ from xblock.fields import Dict, Scope, String
13
+ from xblock.validation import ValidationMessage
14
+
15
+ from .llm import get_llm_response
16
+ from .base import AIEvalXBlock
17
+ from .utils import (
18
+ submit_code,
19
+ get_submission_result,
20
+ SUPPORTED_LANGUAGE_MAP,
21
+ LanguageLabels,
22
+ )
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ USER_RESPONSE = "USER_RESPONSE"
27
+ AI_EVALUATION = "AI_EVALUATION"
28
+ CODE_EXEC_RESULT = "CODE_EXEC_RESULT"
29
+
30
+
31
+ class CodingAIEvalXBlock(AIEvalXBlock):
32
+ """
33
+ TO-DO: document what your XBlock does.
34
+ """
35
+
36
+ has_author_view = True
37
+
38
+ display_name = String(
39
+ display_name=_("Display Name"),
40
+ help=_("Name of the component in the studio"),
41
+ default="Coding with AI Evaluation",
42
+ scope=Scope.settings,
43
+ )
44
+
45
+ judge0_api_key = String(
46
+ display_name=_("Judge0 API Key"),
47
+ help=_(
48
+ "Enter your the Judge0 API key used to execute code on Judge0."
49
+ " Get your key at https://rapidapi.com/judge0-official/api/judge0-ce."
50
+ ),
51
+ default="",
52
+ scope=Scope.settings,
53
+ )
54
+
55
+ language = String(
56
+ display_name=_("Programming Language"),
57
+ help=_("The programming language used for this Xblock."),
58
+ values=[
59
+ {"display_name": language, "value": language}
60
+ for language in SUPPORTED_LANGUAGE_MAP
61
+ ],
62
+ default=LanguageLabels.Python,
63
+ Scope=Scope.settings,
64
+ )
65
+ messages = Dict(
66
+ help=_("Dictionary with messages"),
67
+ scope=Scope.user_state,
68
+ default={USER_RESPONSE: "", AI_EVALUATION: "", CODE_EXEC_RESULT: {}},
69
+ )
70
+
71
+ editable_fields = AIEvalXBlock.editable_fields + ("judge0_api_key", "language")
72
+
73
+ def resource_string(self, path):
74
+ """Handy helper for getting resources from our kit."""
75
+ data = pkg_resources.resource_string(__name__, path)
76
+ return data.decode("utf8")
77
+
78
+ def student_view(self, context=None):
79
+ """
80
+ The primary view of the CodingAIEvalXBlock, shown to students
81
+ when viewing courses.
82
+ """
83
+ html = self.loader.render_django_template(
84
+ "/templates/coding_ai_eval.html",
85
+ {
86
+ "self": self,
87
+ },
88
+ )
89
+
90
+ frag = Fragment(html)
91
+ frag.add_css(self.resource_string("static/css/coding_ai_eval.css"))
92
+ frag.add_javascript(self.resource_string("static/js/src/utils.js"))
93
+
94
+ frag.add_javascript(self.resource_string("static/js/src/coding_ai_eval.js"))
95
+
96
+ monaco_html = self.loader.render_django_template(
97
+ "/templates/monaco.html",
98
+ {
99
+ "monaco_language": SUPPORTED_LANGUAGE_MAP[self.language].monaco_id,
100
+ },
101
+ )
102
+ marked_html = self.resource_string("static/html/marked-iframe.html")
103
+ js_data = {
104
+ "monaco_html": monaco_html,
105
+ "question": self.question,
106
+ "code": self.messages[USER_RESPONSE],
107
+ "ai_evaluation": self.messages[AI_EVALUATION],
108
+ "code_exec_result": self.messages[CODE_EXEC_RESULT],
109
+ "marked_html": marked_html,
110
+ "language": self.language,
111
+ }
112
+ frag.initialize_js("CodingAIEvalXBlock", js_data)
113
+ return frag
114
+
115
+ def author_view(self, context=None):
116
+ """
117
+ Create preview to be show to course authors in Studio.
118
+ """
119
+ if not self.validate():
120
+ fragment = Fragment()
121
+ fragment.add_content(
122
+ _(
123
+ "To ensure this component works correctly, please fix the validation issues."
124
+ )
125
+ )
126
+ return fragment
127
+
128
+ return self.student_view(context=context)
129
+
130
+ def validate_field_data(self, validation, data):
131
+ """
132
+ Validate fields
133
+ """
134
+
135
+ super().validate_field_data(validation, data)
136
+
137
+ if data.language != LanguageLabels.HTML_CSS and not data.judge0_api_key:
138
+ validation.add(
139
+ ValidationMessage(
140
+ ValidationMessage.ERROR, _("Judge0 API key is mandatory")
141
+ )
142
+ )
143
+
144
+ @XBlock.json_handler
145
+ def get_response(self, data, suffix=""): # pylint: disable=unused-argument
146
+ """Get LLM feedback."""
147
+
148
+ answer = f"""
149
+ student code :
150
+
151
+ {data['code']}
152
+ """
153
+
154
+ # stdout and stderr only for executable languages (non HTML)
155
+ if self.language != LanguageLabels.HTML_CSS:
156
+ answer += f"""
157
+ stdout:
158
+
159
+ {data['stdout']}
160
+
161
+ stderr:
162
+
163
+ {data['stderr']}
164
+ """
165
+
166
+ messages = [
167
+ {
168
+ "role": "system",
169
+ "content": f"""
170
+ {self.evaluation_prompt}
171
+
172
+ {self.question}.
173
+
174
+ The programmimg language is {self.language}
175
+
176
+ Evaluation must be in Makrdown format.
177
+ """,
178
+ },
179
+ {
180
+ "content": f""" Here is the student's answer:
181
+ {answer}
182
+ """,
183
+ "role": "user",
184
+ },
185
+ ]
186
+
187
+ try:
188
+ response = get_llm_response(
189
+ self.model,
190
+ self.get_model_api_key(),
191
+ messages,
192
+ self.get_model_api_url(),
193
+ )
194
+
195
+ except Exception as e:
196
+ traceback.print_exc()
197
+ logger.error(
198
+ f"Failed while making LLM request using model {self.model}. Eaised error type: {type(e)}, Error: {e}"
199
+ )
200
+ raise JsonHandlerError(500, "A probem occured. Please retry.") from e
201
+
202
+ if response:
203
+ self.messages[USER_RESPONSE] = data["code"]
204
+ self.messages[AI_EVALUATION] = response
205
+ self.messages[CODE_EXEC_RESULT] = {
206
+ "stdout": data["stdout"],
207
+ "stderr": data["stderr"],
208
+ }
209
+ return {"response": response}
210
+
211
+ raise JsonHandlerError(500, "No AI Evaluation available. Please retry.")
212
+
213
+ @XBlock.json_handler
214
+ def submit_code_handler(self, data, suffix=""): # pylint: disable=unused-argument
215
+ """
216
+ Submit code to Judge0.
217
+ """
218
+ submission_id = submit_code(
219
+ self.judge0_api_key, data["user_code"], self.language
220
+ )
221
+ return {"submission_id": submission_id}
222
+
223
+ @XBlock.json_handler
224
+ def reset_handler(self, data, suffix=""): # pylint: disable=unused-argument
225
+ """
226
+ Reset the Xblock.
227
+ """
228
+ self.messages = {USER_RESPONSE: "", AI_EVALUATION: "", CODE_EXEC_RESULT: {}}
229
+ return {"message": "reset successful."}
230
+
231
+ @XBlock.json_handler
232
+ def get_submission_result_handler(
233
+ self, data, suffix=""
234
+ ): # pylint: disable=unused-argument
235
+ """
236
+ Get code submission result.
237
+ """
238
+ submission_id = data["submission_id"]
239
+ return get_submission_result(self.judge0_api_key, submission_id)
240
+
241
+ @staticmethod
242
+ def workbench_scenarios():
243
+ """A canned scenario for display in the workbench."""
244
+ return [
245
+ (
246
+ "CodingAIEvalXBlock",
247
+ """<coding_ai_eval/>
248
+ """,
249
+ ),
250
+ (
251
+ "Multiple CodingAIEvalXBlock",
252
+ """<vertical_demo>
253
+ <coding_ai_eval/>
254
+ <coding_ai_eval/>
255
+ <coding_ai_eval/>
256
+ </vertical_demo>
257
+ """,
258
+ ),
259
+ ]
ai_eval/compat.py ADDED
@@ -0,0 +1,75 @@
1
+ """Compatibility layer for Open edX."""
2
+
3
+ from typing import Any
4
+
5
+ from django.conf import settings
6
+
7
+
8
+ def _get_current_site_configuration_value(key: str, default: Any = None) -> Any: # pragma: no cover
9
+ """
10
+ Get value from the current site configuration.
11
+
12
+ Args:
13
+ key: The key to retrieve from the site configuration.
14
+ default: The default value to return if the key is not found.
15
+ Returns:
16
+ The value associated with the key, or the default value.
17
+ """
18
+ # pylint: disable=import-error,import-outside-toplevel
19
+ from openedx.core.djangoapps.site_configuration.helpers import get_value
20
+
21
+ return get_value(key, default)
22
+
23
+
24
+ def _get_site_configuration_value(domain: str, key: str, default: Any = None) -> Any: # pragma: no cover
25
+ """
26
+ Get value from the site configuration for a given domain.
27
+
28
+ Args:
29
+ domain: The domain to retrieve site configuration for.
30
+ key: The key to retrieve from the site configuration.
31
+ default: The default value to return if the key is not found.
32
+
33
+ Returns:
34
+ The value associated with the key, or the default value.
35
+ """
36
+ # pylint: disable=import-error,import-outside-toplevel
37
+ from openedx.core.djangoapps.site_configuration.models import SiteConfiguration
38
+
39
+ try:
40
+ config = SiteConfiguration.objects.get(site__domain=domain).site_values
41
+ return config.get(key, default)
42
+ except SiteConfiguration.DoesNotExist:
43
+ return default
44
+
45
+
46
+ def get_site_configuration_value(block_settings_key: str, config_key: str) -> str | None:
47
+ """
48
+ Retrieve configuration value from site configuration based on execution context.
49
+
50
+ In Open edX, site configurations are defined separately for LMS and CMS (Studio)
51
+ environments. API keys are typically stored in the LMS site configuration.
52
+ This function handles the different contexts:
53
+
54
+ In LMS: Get the API key directly from the current site configuration.
55
+ In CMS: Get the API key using LMS site configuration.
56
+ The LMS domain is retrieved from CMS site configuration or Django settings.
57
+
58
+ This special handling is necessary because when an XBlock is being edited in Studio,
59
+ it needs to access API keys that are stored in the corresponding LMS site configuration,
60
+ not in the Studio site configuration.
61
+
62
+ Args:
63
+ block_settings_key: The key under which block settings are stored.
64
+ config_key: Configuration key to retrieve.
65
+
66
+ Returns:
67
+ The configuration value if found, None otherwise.
68
+ """
69
+ if getattr(settings, "SERVICE_VARIANT", None) == "lms":
70
+ block_config = _get_current_site_configuration_value(block_settings_key, {})
71
+ return block_config.get(config_key)
72
+
73
+ lms_base = _get_current_site_configuration_value("LMS_BASE", getattr(settings, "LMS_BASE", None))
74
+ block_config = _get_site_configuration_value(lms_base, block_settings_key, {})
75
+ return block_config.get(config_key)
ai_eval/llm.py ADDED
@@ -0,0 +1,66 @@
1
+ """
2
+ Integration with LLMs.
3
+ """
4
+
5
+ from enum import Enum
6
+ from litellm import completion
7
+
8
+
9
+ class SupportedModels(Enum):
10
+ """
11
+ LLM Models supported by the CodingAIEvalXBlock and ShortAnswerAIEvalXBlock
12
+ """
13
+
14
+ GPT4O = "gpt-4o"
15
+ GPT4O_MINI = "gpt-4o-mini"
16
+ GEMINI_PRO = "gemini/gemini-pro"
17
+ CLAUDE_SONNET = "claude-3-5-sonnet-20240620"
18
+ LLAMA = "ollama/llama2"
19
+
20
+ @staticmethod
21
+ def list():
22
+ return [str(m.value) for m in SupportedModels]
23
+
24
+
25
+ def get_llm_response(
26
+ model: SupportedModels, api_key: str, messages: list, api_base: str
27
+ ) -> str:
28
+ """
29
+ Get LLm response.
30
+
31
+ Args:
32
+ model (SupportedModels): The model to use for generating the response. This should be an instance of
33
+ the SupportedModels enum, specifying which LLM model to call.
34
+ api_key (str): The API key required for authenticating with the LLM service. This key should be kept
35
+ confidential and used to authorize requests to the service.
36
+ messages (list): A list of message objects to be sent to the LLM. Each message should be a dictionary
37
+ with the following format:
38
+
39
+ {
40
+ "content": str, # The content of the message. This is the text that you want to send to the LLM.
41
+ "role": str # The role of the message sender. This must be one of the following values:
42
+ # "user" - Represents a user message.
43
+ # "system" - Represents a system message, typically used for instructions or context.
44
+ # "assistant" - Represents a response or message from the LLM itself.
45
+ }
46
+
47
+ Example:
48
+ [
49
+ {"content": "Hello, how are you?", "role": "user"},
50
+ {"content": "I'm here to help you.", "role": "assistant"}
51
+ ]
52
+ api_base (str): The base URL of the LLM API endpoint. This is the root URL used to construct the full
53
+ API request URL. This is required only when using Llama which doesn't have an official provider.
54
+
55
+ Returns:
56
+ str: The response text from the LLM. This is typically the generated output based on the provided
57
+ messages.
58
+ """
59
+ kwargs = {}
60
+ if api_base:
61
+ kwargs["api_base"] = api_base
62
+ return (
63
+ completion(model=model, api_key=api_key, messages=messages, **kwargs)
64
+ .choices[0]
65
+ .message.content
66
+ )