Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AI Tutor: test file and initial test for ai_tutor_interaction_controller #56361

Merged
merged 13 commits into from
Feb 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/script/generateSharedConstants.rb
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ def main
CHILD_ACCOUNT_COMPLIANCE_STATES
CENSUS_CONSTANTS
DANCE_SONG_MANIFEST_FILENAME
AI_TUTOR_INTERACTION_SAVE_STATUS
AI_TUTOR_TYPES
)

generate_shared_js_file(shared_content, "#{REPO_DIR}/apps/src/util/sharedConstants.js")
Expand Down
28 changes: 10 additions & 18 deletions apps/src/aiTutor/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import {
AiTutorInteractionSaveStatus,
AiTutorTypes,
} from '@cdo/apps/util/sharedConstants';

export type ChatCompletionMessage = {
id: number;
role: Role;
Expand Down Expand Up @@ -38,23 +43,10 @@ export enum Role {
USER = 'user',
SYSTEM = 'system',
}

export enum Status {
ERROR = 'error',
PROFANITY = 'profanity',
PERSONAL = 'personal',
INAPPROPRIATE = 'inappropriate',
OK = 'ok',
UNKNOWN = 'unknown',
EMAIL = 'email',
ADDRESS = 'address',
PHONE = 'phone',
}

export type Status =
(typeof AiTutorInteractionSaveStatus)[keyof typeof AiTutorInteractionSaveStatus];
export const Status = AiTutorInteractionSaveStatus;
export const PII = [Status.EMAIL, Status.ADDRESS, Status.PHONE];

export enum TutorType {
COMPILATION = 'compilation',
VALIDATION = 'validation',
GENERAL_CHAT = 'general_chat',
}
export type TutorType = (typeof AiTutorTypes)[keyof typeof AiTutorTypes];
export const TutorType = AiTutorTypes;
2 changes: 1 addition & 1 deletion apps/src/aichat/chatApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export async function getChatCompletionMessage(

type OpenaiChatCompletionMessage = {
status?: Status;
role: string;
role: Role;
content: string;
};
type ChatCompletionResponse = {
Expand Down
2 changes: 1 addition & 1 deletion apps/src/aichat/redux/aichatRedux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import {getChatCompletionMessage} from '../chatApi';
import {
ChatCompletionMessage,
AichatLevelProperties,
Status,
Role,
Status,
} from '../types';

const getCurrentTimestamp = () => moment(Date.now()).format('YYYY-MM-DD HH:mm');
Expand Down
16 changes: 4 additions & 12 deletions apps/src/aichat/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {LevelProperties} from '@cdo/apps/lab2/types';
import {AiTutorInteractionSaveStatus} from '../util/sharedConstants';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: use @cdo... syntax for import


// TODO: Ideally this type would only contain keys present in
// translated string JSON files (ex. apps/i18n/aichat/en_us.json).
Expand All @@ -24,18 +25,9 @@ export enum Role {
SYSTEM = 'system',
}

export enum Status {
ERROR = 'error',
PROFANITY = 'profanity',
PERSONAL = 'personal',
INAPPROPRIATE = 'inappropriate',
OK = 'ok',
UNKNOWN = 'unknown',
EMAIL = 'email',
ADDRESS = 'address',
PHONE = 'phone',
}

export type Status =
(typeof AiTutorInteractionSaveStatus)[keyof typeof AiTutorInteractionSaveStatus];
export const Status = AiTutorInteractionSaveStatus;
export const PII = [Status.EMAIL, Status.ADDRESS, Status.PHONE];

export interface AichatLevelProperties extends LevelProperties {
Expand Down
9 changes: 7 additions & 2 deletions dashboard/app/controllers/ai_tutor_interactions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ class AiTutorInteractionsController < ApplicationController
# POST /ai_tutor_interactions
def create
return render(status: :forbidden, json: {error: 'This user does not have access to AI Tutor'}) unless current_user.has_ai_tutor_access?
return render(status: :not_acceptable, json: {error: 'Staus is unacceptable'}) unless valid_status
@ai_tutor_interaction = AiTutorInteraction.new(ai_tutor_interaction_params)
if @ai_tutor_interaction.save
render json: {message: "successfully created AiTutorInteraction with id: #{@ai_tutor_interaction.id}"}, status: :created
else
render :not_acceptable, json: {error: 'There was an error creating a new AiTutorInteraction.'}
render(status: :not_acceptable, json: {error: 'There was an error creating a new AiTutorInteraction.'})
end
end

Expand All @@ -26,13 +27,17 @@ def ai_tutor_interaction_params
ai_tutor_interaction_params[:user_id] = current_user.id
ai_tutor_interaction_params[:ai_model_version] = SharedConstants::AI_TUTOR_CHAT_MODEL_VERISON
if params[:isProjectBacked]
project_data = find_project_and_version_id(params[:levelId], params[:scriptId])
project_data = find_project_and_version_id(params[:level_id], params[:script_id])
ai_tutor_interaction_params[:project_id] = project_data[:project_id]
ai_tutor_interaction_params[:project_version_id] = project_data[:version_id]
end
ai_tutor_interaction_params
end

def valid_status
SharedConstants::AI_TUTOR_INTERACTION_SAVE_STATUS.value?(params[:status])
end

def find_project_and_version_id(level_id, script_id)
project_id = nil
version_id = nil
Expand Down
3 changes: 2 additions & 1 deletion dashboard/app/models/ai_tutor_interaction.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,6 @@ class AiTutorInteraction < ApplicationRecord
belongs_to :user
belongs_to :level, optional: true
belongs_to :script, optional: true
validates :type, inclusion: {in: %w(compilation validation general_chat)}
validates :type, inclusion: {in: SharedConstants::AI_TUTOR_TYPES.values}
validates :status, inclusion: {in: SharedConstants::AI_TUTOR_INTERACTION_SAVE_STATUS.values}
end
129 changes: 129 additions & 0 deletions dashboard/test/controllers/ai_tutor_interactions_controller_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
require 'test_helper'

class AiTutorInteractionsControllerTest < ActionController::TestCase
setup do
@student_with_ai_tutor_access = create :student_with_ai_tutor_access
@student = create :student
end

test "create AI Tutor Interaction with valid params" do
sign_in @student_with_ai_tutor_access
assert_creates(AiTutorInteraction) do
post :create, params: {
level_id: 1234,
script_id: 987,
type: SharedConstants::AI_TUTOR_TYPES[:GENERAL_CHAT],
prompt: "Can you help me?",
status: SharedConstants::AI_TUTOR_INTERACTION_SAVE_STATUS[:OK],
ai_response: "Yes, I can help."
}
end
end

test "student without access can not create AI Tutor Interaction" do
sign_in @student
assert_does_not_create(AiTutorInteraction) do
post :create, params: {
level_id: 5678,
script_id: 246,
type: SharedConstants::AI_TUTOR_TYPES[:GENERAL_CHAT],
prompt: "Can you help me?",
status: SharedConstants::AI_TUTOR_INTERACTION_SAVE_STATUS[:OK],
ai_response: "Yes, I can help."
}
end
assert_response :forbidden
end

test "does not create AI Tutor Interaction with invalid type param" do
sign_in @student_with_ai_tutor_access
assert_does_not_create(AiTutorInteraction) do
post :create, params: {
level_id: 1234,
script_id: 987,
type: "trash can",
prompt: "Can you help me?",
status: SharedConstants::AI_TUTOR_INTERACTION_SAVE_STATUS[:OK],
ai_response: "Yes, I can help."
}
end
assert_response :not_acceptable
assert_includes(@response.body, "There was an error creating a new AiTutorInteraction.")
end

test "does not create AI Tutor Interaction with invalid status param" do
sign_in @student_with_ai_tutor_access
assert_does_not_create(AiTutorInteraction) do
post :create, params: {
level_id: 1234,
script_id: 987,
type: SharedConstants::AI_TUTOR_TYPES[:GENERAL_CHAT],
prompt: "Can you help me?",
status: "broken",
ai_response: "Yes, I can help."
}
end
assert_response :not_acceptable
assert_includes(@response.body, "Staus is unacceptable")
end

test "create AI Tutor Interaction for project backed level with valid params" do
sign_in @student_with_ai_tutor_access
@level = create(:level, :with_script)
assert_creates(AiTutorInteraction) do
post :create, params: {
level_id: @level.id,
script_id: @level.script_levels.first.script.id,
type: SharedConstants::AI_TUTOR_TYPES[:COMPILATION],
prompt: "Can you help me?",
status: SharedConstants::AI_TUTOR_INTERACTION_SAVE_STATUS[:PROFANITY],
ai_response: "Yes, I can help.",
isProjectBacked: true
}
end
end

test "create AI Tutor Interaction for project backed level in lesson with lesson group with valid params" do
sign_in @student_with_ai_tutor_access
@lesson = create(:lesson, :with_lesson_group)
@level = create(:level)
@script_level = create :script_level, script: @lesson.script, lesson: @lesson, levels: [@level]
@fake_ip = '127.0.0.1'
fake_version_id = "fake-version-id"
@storage_id = create_storage_id_for_user(@student_with_ai_tutor_access.id)
channel_token = ChannelToken.find_or_create_channel_token(@script_level.level, @fake_ip, @storage_id, @script_level.script_id)
@channel_id = channel_token.channel

# Don't actually talk to S3 when running SourceBucket.new
AWS::S3.stubs :create_client
stub_project_source_data(@channel_id)
_, @project_id = storage_decrypt_channel_id(@channel_id)
@version_id = "fake-version-id"

assert_creates(AiTutorInteraction) do
post :create, params: {
level_id: @script_level.levels.first.id,
script_id: @script_level.script.id,
type: SharedConstants::AI_TUTOR_TYPES[:VALIDATION],
prompt: "Why is my test failing?",
status: SharedConstants::AI_TUTOR_INTERACTION_SAVE_STATUS[:OK],
ai_response: "Because your code is wrong.",
isProjectBacked: true
}
created_ai_tutor_interaction = AiTutorInteraction.last
assert created_ai_tutor_interaction.project_id == @project_id.to_s
assert created_ai_tutor_interaction.project_version_id == fake_version_id
end
end

private def stub_project_source_data(channel_id, code: 'fake-code', version_id: 'fake-version-id')
fake_main_json = {source: code}.to_json
fake_source_data = {
status: 'FOUND',
body: StringIO.new(fake_main_json),
version_id: version_id,
last_modified: DateTime.now
}
SourceBucket.any_instance.stubs(:get).with(channel_id, "main.json").returns(fake_source_data)
end
end
20 changes: 20 additions & 0 deletions lib/cdo/shared_constants.rb
Original file line number Diff line number Diff line change
Expand Up @@ -659,4 +659,24 @@ module SharedConstants
# We should always specify a version for the LLM so the results don't unexpectedly change.
# reference: https://platform.openai.com/docs/models/gpt-3-5
AI_TUTOR_CHAT_MODEL_VERISON = 'gpt-3.5-turbo-1106'

# These reflect the 'status' of an AI Tutor Interaction
AI_TUTOR_INTERACTION_SAVE_STATUS = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's cool we can share an object between ruby and typescript!

ERROR: 'error',
PERSONAL: 'personal',
PROFANITY: 'profanity',
INAPPROPRIATE: 'inappropriate',
OK: 'ok',
UNKNOWN: 'unknown',
EMAIL: 'email',
ADDRESS: 'address',
PHONE: 'phone',
}.freeze

# These are the types of assistance AI Tutor can provide
AI_TUTOR_TYPES = {
COMPILATION: 'compilation',
VALIDATION: 'validation',
GENERAL_CHAT: 'general_chat',
}.freeze
end