Skip to content

Commit

Permalink
Allow users to configure a personal OpenAI API key (#1496)
Browse files Browse the repository at this point in the history
Co-authored-by: Karol <git@koehn.pro>
Co-authored-by: Sebastian Serth <Sebastian.Serth@hpi.de>
  • Loading branch information
3 people authored Aug 1, 2024
1 parent b46f6ff commit 62ed5e3
Show file tree
Hide file tree
Showing 36 changed files with 457 additions and 184 deletions.
8 changes: 3 additions & 5 deletions app/controllers/tasks_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -209,12 +209,10 @@ def export_external_confirm
# rubocop:enable Metrics/AbcSize

def generate_test
TaskService::GptGenerateTests.call(task: @task)
GptService::GenerateTests.call(task: @task, openai_api_key: current_user.openai_api_key)
flash[:notice] = I18n.t('tasks.task_service.gpt_generate_tests.successful_generation')
rescue Gpt::MissingLanguageError
flash[:alert] = I18n.t('tasks.task_service.gpt_generate_tests.no_language')
rescue Gpt::InvalidTaskDescription
flash[:alert] = I18n.t('tasks.task_service.gpt_generate_tests.invalid_description')
rescue Gpt::Error => e
flash[:alert] = e.localized_message
ensure
redirect_to @task
end
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/users/registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def configure_sign_up_params

# If you have extra params to permit, append them to the sanitizer.
def configure_account_update_params
devise_parameter_sanitizer.permit(:account_update, keys: %i[first_name last_name avatar])
devise_parameter_sanitizer.permit(:account_update, keys: %i[first_name last_name avatar openai_api_key])
end

def after_update_path_for(resource)
Expand Down
17 changes: 17 additions & 0 deletions app/errors/gpt/error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true

module Gpt
class Error < ApplicationError
class InternalServerError < Error; end

class InvalidApiKey < Error; end

class InvalidTaskDescription < Error; end

class MissingLanguage < Error; end

def localized_message
I18n.t("errors.gpt.#{self.class.name&.demodulize&.underscore}")
end
end
end
5 changes: 0 additions & 5 deletions app/errors/gpt/invalid_task_description.rb

This file was deleted.

5 changes: 0 additions & 5 deletions app/errors/gpt/missing_language_error.rb

This file was deleted.

9 changes: 9 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class User < ApplicationRecord
validates :email, presence: true, uniqueness: {case_sensitive: false}
validates :first_name, :last_name, :status_group, presence: true
validates :password_set, inclusion: [true, false]
validate :validate_openai_api_key, if: -> { openai_api_key.present? }

has_many :tasks, dependent: :nullify

Expand Down Expand Up @@ -148,6 +149,14 @@ def to_s

private

def validate_openai_api_key
return unless openai_api_key_changed?

GptService::ValidateApiKey.call(openai_api_key:)
rescue Gpt::Error::InvalidApiKey
errors.add(:base, :invalid_api_key)
end

def avatar_format
avatar_blob = avatar.blob
if avatar_blob.content_type.start_with? 'image/'
Expand Down
2 changes: 1 addition & 1 deletion app/policies/task_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def manage?
end

def generate_test?
Settings.open_ai.access_token.present? and update?
user&.openai_api_key.present? and update?
end

private
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# frozen_string_literal: true

module TaskService
class GptGenerateTests < ServiceBase
def initialize(task:)
module GptService
class GenerateTests < GptServiceBase
def initialize(task:, openai_api_key:)
super()
raise Gpt::MissingLanguageError if task.programming_language&.language.blank?
raise Gpt::Error::MissingLanguage if task.programming_language&.language.blank?

@task = task
@client = new_client! openai_api_key
end

def execute
Expand All @@ -20,35 +21,33 @@ def execute

private

def client
@client ||= OpenAI::Client.new
end

def gpt_response
# train client with some prompts
messages = training_prompts.map do |prompt|
{role: 'system', content: prompt}
end
wrap_api_error! do
# train client with some prompts
messages = training_prompts.map do |prompt|
{role: 'system', content: prompt}
end

# send user message
messages << {role: 'user', content: @task.description}
# send user message
messages << {role: 'user', content: @task.description}

# create gpt client
response = client.chat(
parameters: {
model: Settings.open_ai.model,
messages:,
temperature: 0.7, # Lower values insure reproducibility
}
)
# create gpt client
response = @client.chat(
parameters: {
model: Settings.open_ai.model,
messages:,
temperature: 0.7, # Lower values insure reproducibility
}
)

# parse out the response
raw_response = response.dig('choices', 0, 'message', 'content')
# parse out the response
raw_response = response.dig('choices', 0, 'message', 'content')

# check for ``` in the response and extract the text between the first set
raise Gpt::InvalidTaskDescription unless raw_response.include?('```')
# check for ``` in the response and extract the text between the first set
raise Gpt::Error::InvalidTaskDescription unless raw_response.include?('```')

raw_response[/```(.*?)```/m, 1].lines[1..]&.join&.strip
raw_response[/```(.*?)```/m, 1].lines[1..]&.join&.strip
end
end

def training_prompts
Expand Down
23 changes: 23 additions & 0 deletions app/services/gpt_service/gpt_service_base.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

module GptService
class GptServiceBase < ServiceBase
def new_client!(access_token)
raise Gpt::Error::InvalidApiKey if access_token.blank?

OpenAI::Client.new(access_token:)
end

private

def wrap_api_error!
yield
rescue Faraday::UnauthorizedError, OpenAI::Error => e
raise Gpt::Error::InvalidApiKey.new("Could not authenticate with OpenAI: #{e.message}")
rescue Faraday::Error => e
raise Gpt::Error::InternalServerError.new("Could not communicate with OpenAI: #{e.inspect}")
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, SocketError, EOFError => e
raise Gpt::Error.new(e)
end
end
end
22 changes: 22 additions & 0 deletions app/services/gpt_service/validate_api_key.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

module GptService
class ValidateApiKey < GptServiceBase
def initialize(openai_api_key:)
super()

@client = new_client! openai_api_key
end

def execute
validate!
end

def validate!
wrap_api_error! do
response = @client.models.list
raise Gpt::Error::InvalidApiKey unless response['data']
end
end
end
end
13 changes: 11 additions & 2 deletions app/views/tasks/show.html.slim
Original file line number Diff line number Diff line change
Expand Up @@ -574,9 +574,18 @@
=< t('common.button.duplicate')

- if policy(@task).generate_test?
= link_to generate_test_task_path(@task), method: :post, class: 'btn btn-important' do
= link_to generate_test_task_path(@task),
method: :post,
class: 'btn btn-important' do
i.fa-solid.fa-wand-magic-sparkles
=< t('.button.generate_test')
= t('.button.generate_test')
- elsif policy(@task).update?
div data-bs-toggle='tooltip' title=t('.button.api_key_required') data-bs-delay=150
= link_to generate_test_task_path(@task),
method: :post,
class: 'btn btn-important disabled' do
i.fa-solid.fa-wand-magic-sparkles
= t('.button.generate_test')

- if current_user.present?
= link_to t('common.button.back'), tasks_path, class: 'btn btn-important'
Expand Down
10 changes: 10 additions & 0 deletions app/views/users/registrations/edit.html.slim
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@
autocomplete: 'new-password',
class: 'form-control'

.form-group.field-element
= f.label :openai_api_key, t('users.show.openai_api_key'), class: 'form-label'
= f.text_field :openai_api_key,
required: false,
placeholder: t('users.show.openai_api_key'),
autocomplete: 'off',
class: 'form-control'
small.form-text.text-body-secondary
= t('.openai_api_key_usage_html', openai_api_link: 'https://platform.openai.com/api-keys')

= render 'avatar_form', f:

- if resource.password_set?
Expand Down
9 changes: 9 additions & 0 deletions app/views/users/show.html.slim
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@
| :
.col.row-value
= @user.email
.row
.col-auto.row-label
= t('.openai_api_key')
| :
.col.row-value
- if @user.openai_api_key.present?
= t('.openai_api_key_provided')
- else
= t('.openai_api_key_not_provided')
.row.vertical
.col.row-label
= t('.account_links.created')
Expand Down
7 changes: 0 additions & 7 deletions config/initializers/open_ai.rb

This file was deleted.

2 changes: 0 additions & 2 deletions config/locales/de/controllers/tasks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,4 @@ de:
task_found: 'In der externen App wurde eine entsprechende Aufgabe gefunden. Sie können: <ul><li><b>Überschreiben</b> Sie die Aufgabe in der externen App. Dadurch werden alle Änderungen, die in CodeHarbor vorgenommen wurden, auf die externe App übertragen.<br>Vorsicht: Dadurch werden alle potenziellen Änderungen in der externen App überschrieben. Dadurch wird die Aufgabe geändert (und möglicherweise zerstört), auch wenn sie derzeit in einem Kurs verwendet wird.</li><li><b>Erstellen Sie eine neue</b> Aufgabe, die den aktuellen Zustand dieser Aufgabe kopiert. Dadurch wird eine Kopie dieser Aufgabe in CodeHarbor erstellt, die dann als völlig neue Aufgabe in der externen App exportiert wird.</li></ul>'
task_found_no_right: 'In der externen App wurde eine entsprechende Aufgabe gefunden, aber Sie haben keine Rechte, sie zu bearbeiten. Sie können: <ul><li><b>Eine neue Aufgabe erstellen</b>, die den aktuellen Zustand dieser Aufgabe kopiert. Dadurch wird eine Kopie dieser Aufgabe in CodeHarbor erstellt, die dann als völlig neue Aufgabe in der externen App exportiert wird.</li></ul>'
gpt_generate_tests:
invalid_description: Die angegebene Aufgabenbeschreibung stellt keine gültige Programmieraufgabe dar und kann daher nicht zum Generieren eines Unit-Tests genutzt werden. Bitte stellen Sie sicher, dass die Aufgabenbeschreibung eine klar formulierte Problemstellung enthält, die durch ein Programm gelöst werden kann.
no_language: Für diese Aufgabe ist keine Programmiersprache angegeben. Bitte geben Sie die Sprache an, bevor Sie fortfahren.
successful_generation: Unit-Test erfolgreich generiert. Bitte überprüfen Sie den generierten Test und vergeben Sie einen passenden Dateinamen.
9 changes: 9 additions & 0 deletions config/locales/de/errors.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
de:
errors:
gpt:
error: Beim Generieren des Unit-Tests ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.
internal_server_error: Der OpenAI-Server ist derzeit nicht verfügbar. Bitte versuchen Sie es später erneut, wenn das Problem behoben wurde.
invalid_api_key: Der API-Schlüssel in Ihrem Profil ist ungültig oder abgelaufen. Bitte aktualisieren Sie den API-Schlüssel in Ihrem Profil um die KI-Funktionen zu nutzen und versuchen Sie es anschließend erneut.
invalid_task_description: Die angegebene Aufgabenbeschreibung stellt keine gültige Programmieraufgabe dar und kann daher nicht zum Generieren eines Unit-Tests genutzt werden. Bitte stellen Sie sicher, dass die Aufgabenbeschreibung eine klar formulierte Problemstellung enthält, die durch ein Programm gelöst werden kann.
missing_language: Für diese Aufgabe ist keine Programmiersprache angegeben. Bitte geben Sie die Sprache an, bevor Sie fortfahren.
1 change: 1 addition & 0 deletions config/locales/de/models.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ de:
avatar:
not_an_image: muss ein Bild sein
size_over_10_mb: Größe muss kleiner als 10MB sein
invalid_api_key: Der API-Schlüssel in Ihrem Profil ist ungültig. Bitte fügen Sie den entsprechenden API-Schlüssel in Ihrem Profil hinzu, um die KI-Funktionen zu nutzen.
models:
account_link:
one: Account-Link
Expand Down
1 change: 1 addition & 0 deletions config/locales/de/views/tasks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ de:
add_to_collection_hint: Speichern Sie Aufgaben für später, indem Sie sie zu einer Sammlung hinzufügen.
button:
add_to_collection: Zu Sammlung hinzufügen
api_key_required: OpenAI API-Schlüssel ist erforderlich, um einen Test zu erstellen
create_collection: Neue Sammlung anlegen
download_as_zip: Diese Aufgabe als ZIP-Datei herunterladen.
export: Exportieren
Expand Down
4 changes: 4 additions & 0 deletions config/locales/de/views/users.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ de:
add_identity: Account mit %{kind} verknüpfen
cannot_remove_last_identity: Account-Verknüpfung zu %{kind} kann nicht entfernt werden, da weder ein Passwort gesetzt noch eine andere Identität verknüpft ist
manage_omniauth: Verknüpfte Accounts verwalten
openai_api_key_usage_html: Geben Sie hier Ihren OpenAI-API-Schlüssel ein, um verschiedene KI-Funktionen innerhalb der Plattform zu nutzen. Sie können einen API-Schlüssel auf der <a href='%{openai_api_link}' target='_blank' rel='noopener noreferrer'>OpenAI-Website</a> erstellen.
remove_identity: Account-Verknüpfung zu %{kind} entfernen
shared:
notification_modal:
Expand All @@ -51,6 +52,9 @@ de:
delete_modal:
title: Warnung
full_name: Vollständiger Name
openai_api_key: OpenAI API-Schlüssel
openai_api_key_not_provided: Nicht eingegeben
openai_api_key_provided: Eingegeben
private_information: Private Informationen
public_information: Öffentliche Informationen
send_message: Nachricht senden
2 changes: 0 additions & 2 deletions config/locales/en/controllers/tasks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,4 @@ en:
task_found: 'A corresponding task has been found on the external app. You can: <ul><li><b>Overwrite</b> the task on the external app. This will transfer all changes made on CodeHarbor to the external app.<br>Careful: This will overwrite all potential changes made on the external app. This will change (and might break) the task, even if it is currently in use by a course.</li><li><b>Create a new</b> task which copies the current state of this task. This will create a copy of this task on CodeHarbor, which will then be exported as a completely new exercise to the external app.</li></ul>'
task_found_no_right: 'A corresponding task has been found on external app, but you don''t have the rights to edit it. You can: <ul><li><b>Create a new</b> task which copies the current state of this task. This will create a copy of this task on CodeHarbor, which will then be exported as a completely new task to the external app.</li></ul>'
gpt_generate_tests:
invalid_description: The task description provided does not represent a valid programming task and therefore cannot be used to generate a unit test. Please make sure that the task description contains a clearly formulated problem that can be solved by a program.
no_language: Programming language is not specified for this task. Please specify the language before proceeding.
successful_generation: Unit test generated successfully. Please check the generated test and assign an appropriate filename.
9 changes: 9 additions & 0 deletions config/locales/en/errors.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
en:
errors:
gpt:
error: An error occurred while generating the unit test. Please try again later.
internal_server_error: The OpenAI server is currently experiencing an outage. Please try again later when the issue is fixed.
invalid_api_key: The API key in your profile is invalid or has expired. Please update the API key in your profile to use the AI features and try again.
invalid_task_description: The task description provided does not represent a valid programming task and therefore cannot be used to generate a unit test. Please make sure that the task description contains a clearly formulated problem that can be solved by a program.
missing_language: Programming language is not specified for this task. Please specify the language before proceeding.
1 change: 1 addition & 0 deletions config/locales/en/models.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ en:
avatar:
not_an_image: needs to be an image
size_over_10_mb: size needs to be less than 10MB
invalid_api_key: The API key in your profile is invalid. Please add the appropriate API key in your profile to use the AI features.
models:
account_link:
one: Account Link
Expand Down
1 change: 1 addition & 0 deletions config/locales/en/views/tasks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ en:
add_to_collection_hint: Save Tasks for later by adding them to a collection.
button:
add_to_collection: Add to Collection
api_key_required: OpenAI API key is required to generate a test
create_collection: Create new Collection
download_as_zip: Download this Task as a ZIP file.
export: Export
Expand Down
4 changes: 4 additions & 0 deletions config/locales/en/views/users.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ en:
add_identity: Link %{kind} account
cannot_remove_last_identity: Cannot remove account link to %{kind} because neither a password is set nor another identity is linked
manage_omniauth: Manage linked accounts
openai_api_key_usage_html: Enter your OpenAI API key here to use various AI features within the platform. You can create an API key on the <a href='%{openai_api_link}' target='_blank' rel='noopener noreferrer'>OpenAI website</a>.
remove_identity: Remove account link to %{kind}
shared:
notification_modal:
Expand All @@ -51,6 +52,9 @@ en:
delete_modal:
title: Warning
full_name: Full name
openai_api_key: OpenAI API Key
openai_api_key_not_provided: Not entered
openai_api_key_provided: Entered
private_information: Private Information
public_information: Public Information
send_message: Send Message
1 change: 0 additions & 1 deletion config/settings/development.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,4 @@ omniauth:
oai_pmh:
admin_mail: admin@example.org
open_ai:
access_token: ~ # Add a valid API key from https://platform.openai.com/api-keys
model: gpt-4o-mini
1 change: 0 additions & 1 deletion config/settings/production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,4 @@ omniauth:
oai_pmh:
admin_mail: admin@example.org
open_ai:
access_token: ~ # Add a valid API key from https://platform.openai.com/api-keys
model: gpt-4o-mini
1 change: 0 additions & 1 deletion config/settings/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,4 @@ nbp:
name: CodeHarbor
slug: CoHaP2
open_ai:
access_token: ~ # Add a valid API key from https://platform.openai.com/api-keys
model: gpt-4o-mini
7 changes: 7 additions & 0 deletions db/migrate/20240703221801_add_openai_api_key_to_users.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class AddOpenaiApiKeyToUsers < ActiveRecord::Migration[7.1]
def change
add_column :users, :openai_api_key, :string
end
end
Loading

0 comments on commit 62ed5e3

Please sign in to comment.