Skip to content

Commit f1e0e5f

Browse files
committed
Merge remote-tracking branch 'thesis/seemantsingh/thesis/main' into feature/ChatGPTIntegration
2 parents 3fa9163 + eee719c commit f1e0e5f

30 files changed

+708
-3
lines changed

AI_INTEGRATION_README.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# AI Feedback Integration in CodeOcean
2+
3+
This project integrates ChatGPT into CodeOcean to provide automated feedback for both **Request for Comments (RFCs)** and **test results**.
4+
5+
## Prerequisites
6+
7+
- **API Key:** Stored securely via Rails credentials
8+
- **Internal User:** Requires an internal user (`chatgpt@example.org`) to create comments
9+
- **Gem Required:** `gem 'ruby-openai'`
10+
11+
## Overview
12+
13+
- **AI API:** OpenAI Chat Completions
14+
- **Jobs:** Asynchronous processing via Solid Queue
15+
- **Frontend:** Adds "Request Feedback from AI" buttons for test results on Score
16+
17+
## Key Components
18+
19+
### ChatGPT Service
20+
21+
Encapsulates API communication with ChatGPT.
22+
23+
- **Implementation:** [`app/services/chat_gpt_service/chat_gpt_request.rb`](app/services/chat_gpt_service/chat_gpt_request.rb)
24+
- **Prompt files:** [`app/services/chat_gpt_service/chat_gpt_prompts/`](app/services/chat_gpt_service/chat_gpt_prompts/) (EN and DE versions)
25+
- **Structured Output Schema:** [`app/services/chat_gpt_service/chat_gpt_prompts/response_format.json`](app/services/chat_gpt_service/chat_gpt_prompts/response_format.json)
26+
27+
**Key Method:**
28+
- `execute(prompt, structured_output)`: Sends prompt and receives response
29+
30+
### ChatGPT Helper
31+
32+
Responsible for formatting prompts and parsing responses.
33+
34+
- [`app/helpers/chat_gpt_helper.rb`](app/helpers/chat_gpt_helper.rb)
35+
- `format_prompt`: Loads locale-specific templates and replaces placeholders in the prompt from application
36+
- `format_response`: Parses structured JSON response from chatGPT to create general commenta(line 0) and line comments for RFC.
37+
38+
### Automatic Comment Job (RFC)
39+
40+
Handles background comment generation when students submit a Request for Comments.
41+
42+
- **Job class:** [`GenerateAutomaticCommentsJob`](app/jobs/generate_automatic_comments_job.rb)
43+
- **Service:** Uses `ChatGptRequest` to communicate with the API
44+
- **Process:**
45+
1. Prompts are built from student code and context
46+
2. API response is parsed
47+
3. General and line-specific comments are created
48+
4. Emails are sent using Solid Queue
49+
50+
### ️Request Feedback From AI
51+
52+
Allows students to request feedback per test result after scoring.
53+
54+
- **Output modification:**
55+
[`app/models/submission.rb`](app/models/submission.rb)
56+
Adds `testrun_id` to each test result:
57+
```ruby
58+
output.merge!(filename:, message: feedback_message(file, output), weight: file.weight, hidden_feedback: file.hidden_feedback, testrun_id: testrun.id)
59+
```
60+
61+
- **Frontend integration:**
62+
[`app/assets/javascripts/editor/editor.js.erb`](app/assets/javascripts/editor/editor.js.erb)
63+
```js
64+
card.attr('data-testrun-id', result.testrun_id); // Add testrun_id to the card
65+
```
66+
67+
- **Triggering feedback request:**
68+
[`app/assets/javascripts/editor.js`](app/assets/javascripts/editor.js)
69+
Handles button click, calls backend route, and updates the UI with the ChatGPT feedback.
70+
71+
- **Route and logic:**
72+
- [`app/controllers/submissions_controller.rb`](app/controllers/submissions_controller.rb): Handles `/testrun_ai_feedback_message` route
73+
- [`app/models/testrun.rb`](app/models/testrun.rb): Contains `generate_ai_feedback` method that builds prompt and fetches response
74+
75+
### Exercise-Level Controls
76+
77+
Instructors can toggle AI features per exercise using boolean flags:
78+
79+
- `allow_ai_comment_for_rfc`: Enables RFC-based AI feedback
80+
- `allow_ai_feedback_on_score`: Enables test-based feedback
81+
82+
---

Gemfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ gem 'turbolinks'
5757
gem 'webauthn'
5858
gem 'whenever', require: false
5959
gem 'zxcvbn-ruby', require: 'zxcvbn'
60+
gem 'ruby-openai'
61+
6062

6163
# Error Tracing
6264
gem 'mnemosyne-ruby'

Gemfile.lock

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ GEM
155155
erubi (1.13.1)
156156
et-orbi (1.2.11)
157157
tzinfo
158+
event_stream_parser (1.0.0)
158159
eventmachine (1.2.7)
159160
excon (1.2.3)
160161
execjs (2.10.0)
@@ -167,6 +168,10 @@ GEM
167168
faraday-net_http (>= 2.0, < 3.5)
168169
json
169170
logger
171+
faraday-multipart (1.0.4)
172+
multipart-post (~> 2)
173+
faraday-net_http (3.3.0)
174+
net-http
170175
faraday-net_http (3.4.0)
171176
net-http (>= 0.5.0)
172177
faraday-net_http_persistent (2.3.0)
@@ -281,6 +286,7 @@ GEM
281286
multi_json (1.15.0)
282287
multi_xml (0.7.1)
283288
bigdecimal (~> 3.1)
289+
multipart-post (2.4.1)
284290
nested_form (0.3.2)
285291
net-http (0.6.0)
286292
uri
@@ -466,6 +472,10 @@ GEM
466472
rubocop-rspec_rails (2.30.0)
467473
rubocop (~> 1.61)
468474
rubocop-rspec (~> 3, >= 3.0.1)
475+
ruby-openai (7.3.1)
476+
event_stream_parser (>= 0.3.0, < 2.0.0)
477+
faraday (>= 1)
478+
faraday-multipart (>= 1)
469479
ruby-progressbar (1.13.0)
470480
ruby-vips (2.2.2)
471481
ffi (~> 1.12)
@@ -676,6 +686,7 @@ DEPENDENCIES
676686
rubocop-rails
677687
rubocop-rspec
678688
rubocop-rspec_rails
689+
ruby-openai
679690
rubytree
680691
rubyzip
681692
sassc-rails

app/assets/javascripts/editor.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,50 @@ $(document).on('turbolinks:load', function(event) {
8282

8383
$(document).on('theme:change', handleThemeChangeEvent.bind(this));
8484
});
85+
86+
$(document).on('click', '.ai-feedback-link', function (e) {
87+
e.preventDefault();
88+
89+
const testrunId = $(this).data('testrun_id');
90+
const $button = $(this);
91+
92+
if (!testrunId) {
93+
alert("No Testrun ID available for this feedback.");
94+
return;
95+
}
96+
97+
$.ajax({
98+
url: `/submissions/testrun_ai_feedback`,
99+
type: 'POST',
100+
data: { testrun_id: testrunId },
101+
beforeSend: function () {
102+
$button.html(
103+
'<div class="spinner-border spinner-border-sm" role="status"><span class="visually-hidden">Loading...</span></div>'
104+
);
105+
$button.prop('disabled', true);
106+
},
107+
success: function (response) {
108+
// Find the card containing the button
109+
const card = $button.closest('.card');
110+
111+
if (card.length) {
112+
// Update the feedback message in the card
113+
card.find('.row .col-md-9').eq(2).html(response);
114+
115+
// Hide or remove the button after feedback is displayed
116+
$button.remove();
117+
} else {
118+
console.error("Card not found for testrun_id:", testrunId);
119+
}
120+
},
121+
error: function (xhr) {
122+
alert(`Failed to fetch feedback: ${xhr.responseText}`);
123+
// Re-enable the button in case of an error
124+
$button.html("Request Feedback from AI");
125+
$button.prop('disabled', false);
126+
},
127+
complete: function () {
128+
// No action needed here since button is removed on success
129+
}
130+
});
131+
});

app/assets/javascripts/editor/editor.js.erb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,7 @@ var CodeOceanEditor = {
573573
card.find('.card-title .number').text(index + 1);
574574
card.find('.row .col-md-9').eq(0).find('.number').eq(0).text(result.passed);
575575
card.find('.row .col-md-9').eq(0).find('.number').eq(1).text(result.count);
576+
card.attr('data-testrun-id', result.testrun_id); // Add testrun_id to the card
576577
if (result.weight !== 0) {
577578
card.find('.row .col-md-9').eq(1).find('.number').eq(0).text(parseFloat((result.score * result.weight).toFixed(2)));
578579
card.find('.row .col-md-9').eq(1).find('.number').eq(1).text(result.weight);
@@ -581,6 +582,10 @@ var CodeOceanEditor = {
581582
card.find('.attribute-row.row').eq(1).addClass('d-none');
582583
}
583584
card.find('.row .col-md-9').eq(2).html(result.message);
585+
if (result.testrun_id) {
586+
const aiFeedbackLink = card.find('.ai-feedback-link');
587+
aiFeedbackLink.data('testrun_id', result.testrun_id);
588+
}
584589

585590
// Add error message from code to card
586591
if (result.error_messages) {

app/assets/javascripts/request_for_comments.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,12 @@ $(document).on('turbolinks:load', function () {
6666
// set editor mode (used for syntax highlighting
6767
currentEditor.getSession().setMode($(editor).data('mode'));
6868
currentEditor.setTheme(CodeOceanEditor.THEME);
69-
7069
currentEditor.commentVisualsByLine = {};
7170
setAnnotations(currentEditor, $(editor).data('file-id'));
7271
currentEditor.on("guttermousedown", handleSidebarClick);
7372
currentEditor.on("guttermousemove", showPopover);
73+
currentEditor.getSession().setOption("useWorker", false);
74+
7475
});
7576

7677
const handleAceThemeChangeEvent = function() {

app/controllers/exercises_controller.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,8 @@ def exercise_params
210210
:hide_file_tree,
211211
:allow_file_creation,
212212
:allow_auto_completion,
213+
:allow_ai_comment_for_rfc,
214+
:allow_ai_feedback_on_score,
213215
:title,
214216
:internal_title,
215217
:expected_difficulty,

app/controllers/request_for_comments_controller.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,9 @@ def create
153153
format.json { render json: {danger: t('exercises.editor.depleted'), status: :container_depleted}, status: :service_unavailable }
154154
else
155155
format.json { render :show, status: :created, location: @request_for_comment }
156+
if @request_for_comment.submission.exercise.allow_ai_comment_for_rfc
157+
GenerateAutomaticCommentsJob.perform_later(@request_for_comment, current_user)
158+
end
156159
end
157160
else
158161
format.html { render :new }

app/controllers/submissions_controller.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class SubmissionsController < ApplicationController
2121
skip_before_action :verify_authenticity_token, only: %i[render_file download_file]
2222
skip_before_action :deny_access_from_render_host, only: :render_file
2323
skip_before_action :require_fully_authenticated_user!, only: :render_file
24+
skip_after_action :verify_authorized, only: :testrun_ai_feedback_message
2425

2526
def index
2627
@search = policy_scope(Submission).ransack(params[:q])
@@ -335,6 +336,19 @@ def test
335336
kill_client_socket(client_socket)
336337
end
337338

339+
def testrun_ai_feedback_message
340+
testrun = Testrun.find(params[:testrun_id])
341+
342+
# Use the generate_feedback method from the Testrun model
343+
feedback_message = testrun.generate_ai_feedback
344+
345+
if feedback_message.present?
346+
render plain: feedback_message, status: :ok
347+
else
348+
render plain: 'Failed to generate feedback.', status: :unprocessable_entity
349+
end
350+
end
351+
338352
private
339353

340354
def authorize!

app/helpers/chat_gpt_helper.rb

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
2+
module ChatGptHelper
3+
4+
def self.format_prompt(options)
5+
if I18n.locale == :en
6+
file_path = Rails.root.join('app', 'services/chat_gpt_service/chat_gpt_prompts', 'prompt_en.xml')
7+
prompt = File.read(file_path)
8+
prompt.gsub!("[Learner's Code]", options[:learner_solution] || "")
9+
prompt.gsub!("[Task]", options[:exercise] || "")
10+
prompt.gsub!("[Error Message]", options[:test_results] || "")
11+
prompt.gsub!("[Student Question]", options[:question] || "")
12+
else
13+
file_path = Rails.root.join('app', 'services/chat_gpt_service/chat_gpt_prompts', 'prompt_de.xml')
14+
prompt = File.read(file_path)
15+
prompt.gsub!("[Code des Lernenden]", options[:learner_solution] || "")
16+
prompt.gsub!("[Aufgabenstellung]", options[:exercise] || "")
17+
prompt.gsub!("[Fehlermeldung]", options[:test_results] || "")
18+
prompt.gsub!("[Frage des Studierenden]", options[:question] || "")
19+
end
20+
21+
prompt
22+
end
23+
24+
def self.format_response(response)
25+
parsed_response = JSON.parse(response)
26+
requirements_comments = ''
27+
if parsed_response['requirements']
28+
requirements_comments = parsed_response['requirements'].map { |req| req['comment'] }.join("\n")
29+
end
30+
31+
line_specific_comments = []
32+
if parsed_response['line_specific_comments']
33+
line_specific_comments = parsed_response['line_specific_comments'].map do |line_comment|
34+
{
35+
line_number: line_comment['line_number'],
36+
comment: line_comment['comment']
37+
}
38+
end
39+
end
40+
41+
{
42+
requirements_comments: requirements_comments,
43+
line_specific_comments: line_specific_comments
44+
}
45+
end
46+
47+
end

0 commit comments

Comments
 (0)