Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
85 commits
Select commit Hold shift + click to select a range
5cd6ac6
feat: v1 of AI-generated comments
xxdydx Feb 2, 2025
853ba84
feat: added logging of inputs and outputs
xxdydx Feb 13, 2025
4c37d14
Update generate_ai_comments.ex
xxdydx Feb 21, 2025
8192b3d
feat: function to save outputs to database
xxdydx Mar 17, 2025
8a235b3
Format answers json before sending to LLM
EugeneOYZ1203n Mar 18, 2025
d384e06
Add LLM Prompt to question params when submitting assessment xml file
EugeneOYZ1203n Mar 18, 2025
98feac2
Add LLM Prompt to api response when grading view is open
EugeneOYZ1203n Mar 18, 2025
7716d57
feat: added llm_prompt from qn to raw_prompt
xxdydx Mar 19, 2025
df34dbd
feat: enabling/disabling of LLM feature by course level
xxdydx Mar 19, 2025
0a25fa8
feat: added llm_grading boolean field to course creation API
xxdydx Mar 19, 2025
2723f5a
feat: added api key storage in courses & edit api key/enable llm grading
xxdydx Mar 26, 2025
02f7ed1
feat: encryption for llm_api_key
xxdydx Apr 2, 2025
cb34984
feat: added final comment editing route
xxdydx Apr 5, 2025
09a7b09
feat: added logging of chosen comments
xxdydx Apr 6, 2025
ed44a7e
fix: bugs when certain fields were missing
xxdydx Apr 6, 2025
3715368
feat: updated tests
xxdydx Apr 6, 2025
5bfe276
formatting
xxdydx Apr 6, 2025
c27b93b
Merge branch 'master' into feat/add-AI-generated-comments-grading
xxdydx Apr 6, 2025
17884fd
fix: error handling when calling openai API
xxdydx Apr 6, 2025
f91cc92
fix: credo issues
xxdydx Apr 9, 2025
81e5bf7
formatting
xxdydx Apr 9, 2025
8f8b93a
Merge branch 'master' into feat/add-AI-generated-comments-grading
RichDom2185 Jun 13, 2025
1ec67d7
Address some comments
Tkaixiang Sep 27, 2025
4f2af5d
Fix formatting
Tkaixiang Sep 27, 2025
ec67aa3
rm IO.inspect
Tkaixiang Sep 27, 2025
11ff272
a
Tkaixiang Sep 27, 2025
1a77f67
Use case instead of if
Tkaixiang Sep 27, 2025
02922e9
Streamlines generate_ai_comments to only send the selected question a…
Tkaixiang Sep 27, 2025
f068aa9
Remove unncessary field
Tkaixiang Sep 27, 2025
5f3ad2c
default: false for llm_grading
Tkaixiang Sep 27, 2025
34d326c
Add proper linking between ai_comments table and submissions. Return …
Tkaixiang Sep 28, 2025
6ff2864
Resolve some migration comments
Tkaixiang Sep 30, 2025
0854822
Add llm_model and llm_api_url to the DB + schema
Tkaixiang Sep 30, 2025
f0ccaf6
Moves api key, api url, llm model and course prompt to course level
Tkaixiang Sep 30, 2025
d14c03e
Add encryption_key to env
Tkaixiang Sep 30, 2025
c345e03
Do not hardcode formatting instructions
Tkaixiang Oct 7, 2025
b782641
Add Assessment level prompts to the XML
Tkaixiang Oct 7, 2025
c4defb9
Return some additional info for composing of prompts
Tkaixiang Oct 7, 2025
04590f4
Remove un-used 'save comments'
Tkaixiang Oct 7, 2025
2920dda
Fix existing assessment tests
Tkaixiang Oct 14, 2025
9088279
Fix generate_ai_comments test cases
Tkaixiang Oct 14, 2025
714476a
Fix bug preventing avengers from generating ai comments
Tkaixiang Oct 14, 2025
5573a21
Fix up tests + error msgs
Tkaixiang Oct 14, 2025
efc4c57
Formatting
Tkaixiang Oct 14, 2025
c68f331
Merge branch 'master' of github.com:source-academy/backend into feat/…
Tkaixiang Oct 14, 2025
aa84560
some mix credo suggestions
Tkaixiang Oct 14, 2025
4537270
format
Tkaixiang Oct 14, 2025
62b6437
Fix credo issue
Tkaixiang Oct 14, 2025
6e769fd
bug fix + credo fixes
Tkaixiang Oct 14, 2025
1bd9398
Fix tests
Tkaixiang Oct 14, 2025
4c341d1
format
Tkaixiang Oct 14, 2025
bd6e677
Merge branch 'master' into feat/add-AI-generated-comments-grading
RichDom2185 Oct 21, 2025
2ad0056
Merge branch 'master' into feat/add-AI-generated-comments-grading
Tkaixiang Oct 26, 2025
2947f3c
Modify test.exs
Tkaixiang Oct 26, 2025
8669e3a
Update lib/cadet_web/controllers/generate_ai_comments.ex
Tkaixiang Oct 26, 2025
58345d4
Copilot feedback
Tkaixiang Oct 26, 2025
353b533
Merge branch 'feat/add-AI-generated-comments-grading' of github.com:s…
Tkaixiang Oct 26, 2025
f794a14
format
Tkaixiang Oct 26, 2025
a9e2b2a
Work on sentry comments
Tkaixiang Oct 26, 2025
3e96d6b
Fix type
Tkaixiang Oct 26, 2025
5bf1aa6
Redate migrations to maintain total order
RichDom2185 Oct 28, 2025
f2c02c9
Add newline at EOF
RichDom2185 Oct 28, 2025
0d2f0c2
Fix indent
RichDom2185 Oct 29, 2025
8cf09b9
Fix capitalization
RichDom2185 Oct 29, 2025
23541e7
Remove llmApiKey from any kind of storage on FE
Tkaixiang Oct 30, 2025
9ecb1d6
Remove indexes
Tkaixiang Oct 30, 2025
e26b33f
rm todo
Tkaixiang Oct 30, 2025
0940dd8
rm todo
Tkaixiang Oct 30, 2025
654c9f4
Re-format ai_comments to reference answer_id instead
Tkaixiang Oct 30, 2025
af395df
Abstract out + remove un-used field
Tkaixiang Oct 30, 2025
f1b8154
Add delimeter + bug fixes
Tkaixiang Oct 30, 2025
3450096
Mix format
Tkaixiang Oct 30, 2025
d02383d
Switch to openAI module
Tkaixiang Oct 30, 2025
e95766e
rm un-used
Tkaixiang Oct 30, 2025
1091e77
Merge all prompts to :prompts to preserve abstraction
Tkaixiang Oct 30, 2025
8b57933
rm
Tkaixiang Oct 30, 2025
818d1d7
Fix formatting
Tkaixiang Oct 30, 2025
9556c25
Fix test
Tkaixiang Oct 30, 2025
c9a650d
Revert some dependency changes
RichDom2185 Oct 31, 2025
ea3bb1f
Update actions versions
RichDom2185 Oct 31, 2025
d43fe02
Improve encrypt + decrypt robustness
Tkaixiang Oct 31, 2025
c0c434a
Fix dialyzer
Tkaixiang Oct 31, 2025
7fae27c
Merge branch 'master' into feat/add-AI-generated-comments-grading
RichDom2185 Nov 4, 2025
6a141a4
Re-factor schema
Tkaixiang Nov 5, 2025
d0a08df
Merge branch 'feat/add-AI-generated-comments-grading' of github.com:s…
Tkaixiang Nov 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,6 @@ erl_crash.dump

# Generated lexer
/src/source_lexer.erl

# Ignore log files
/log
95 changes: 95 additions & 0 deletions lib/cadet/ai_comments.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
defmodule Cadet.AIComments do
@moduledoc """
Handles operations related to AI comments, including creation, updates, and retrieval.
"""

import Ecto.Query
alias Cadet.Repo
alias Cadet.AIComments.AIComment

@doc """
Creates a new AI comment log entry.
"""
def create_ai_comment(attrs \\ %{}) do
%AIComment{}
|> AIComment.changeset(attrs)
|> Repo.insert()
end

@doc """
Gets an AI comment by ID.
"""
def get_ai_comment!(id), do: Repo.get!(AIComment, id)

@doc """
Retrieves an AI comment for a specific submission and question.
Returns `nil` if no comment exists.
"""
def get_ai_comments_for_submission(submission_id, question_id) do
Repo.one(
from(c in AIComment,
where: c.submission_id == ^submission_id and c.question_id == ^question_id
)
)
end

@doc """
Retrieves the latest AI comment for a specific submission and question.
Returns `nil` if no comment exists.
"""
def get_latest_ai_comment(submission_id, question_id) do
Repo.one(
from(c in AIComment,
where: c.submission_id == ^submission_id and c.question_id == ^question_id,
order_by: [desc: c.inserted_at],
limit: 1
)
)
end

@doc """
Updates the final comment for a specific submission and question.
Returns the most recent comment entry for that submission/question.
"""
def update_final_comment(submission_id, question_id, final_comment) do
comment = get_latest_ai_comment(submission_id, question_id)

case comment do
nil ->
{:error, :not_found}

_ ->
comment
|> AIComment.changeset(%{final_comment: final_comment})
|> Repo.update()
end
end

@doc """
Updates an existing AI comment with new attributes.
"""
def update_ai_comment(id, attrs) do
id
|> get_ai_comment!()
|> AIComment.changeset(attrs)
|> Repo.update()
end

@doc """
Updates the chosen comments for a specific submission and question.
Accepts an array of comments and replaces the existing array in the database.
"""
def update_chosen_comments(submission_id, question_id, new_comments) do
comment = get_latest_ai_comment(submission_id, question_id)

case comment do
nil ->
{:error, :not_found}

_ ->
comment
|> AIComment.changeset(%{comment_chosen: new_comments})
|> Repo.update()
end
end
end
36 changes: 36 additions & 0 deletions lib/cadet/ai_comments/ai_comment.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
defmodule Cadet.AIComments.AIComment do
@moduledoc """
Defines the schema and changeset for AI comments.
"""

use Ecto.Schema
import Ecto.Changeset

schema "ai_comment_logs" do
field(:submission_id, :integer)
field(:question_id, :integer)
field(:raw_prompt, :string)
field(:answers_json, :string)
field(:response, :string)
field(:error, :string)
field(:comment_chosen, {:array, :string})
field(:final_comment, :string)

timestamps()
end

def changeset(ai_comment, attrs) do
ai_comment
|> cast(attrs, [
:submission_id,
:question_id,
:raw_prompt,
:answers_json,
:response,
:error,
:comment_chosen,
:final_comment
])
|> validate_required([:submission_id, :question_id, :raw_prompt, :answers_json])
end
end
11 changes: 9 additions & 2 deletions lib/cadet/assessments/assessments.ex
Original file line number Diff line number Diff line change
Expand Up @@ -941,7 +941,7 @@
raw_answer,
force_submit
) do
with {:ok, team} <- find_team(question.assessment.id, cr_id),

Check warning on line 944 in lib/cadet/assessments/assessments.ex

View workflow job for this annotation

GitHub Actions / Run CI

variable "team" is unused (if the variable is not meant to be used, prefix it with an underscore)
{:ok, submission} <- find_or_create_submission(cr, question.assessment),
{:status, true} <- {:status, force_submit or submission.status != :submitted},
{:ok, _answer} <- insert_or_update_answer(submission, question, raw_answer, cr_id) do
Expand Down Expand Up @@ -2289,8 +2289,8 @@
@spec get_answers_in_submission(integer() | String.t()) ::
{:ok, {[Answer.t()], Assessment.t()}}
| {:error, {:bad_request, String.t()}}
def get_answers_in_submission(id) when is_ecto_id(id) do
answer_query =
def get_answers_in_submission(id, question_id \\ nil) when is_ecto_id(id) do
base_query =
Answer
|> where(submission_id: ^id)
|> join(:inner, [a], q in assoc(a, :question))
Expand All @@ -2312,6 +2312,13 @@
{s, student: {st, user: u}, team: {t, team_members: {tm, student: {tms, user: tmu}}}}
)

answer_query =
if is_nil(question_id) do
base_query
else
base_query |> where(question_id: ^question_id)
end

answers =
answer_query
|> Repo.all()
Expand Down Expand Up @@ -2692,7 +2699,7 @@

def has_last_modified_answer?(
question = %Question{},
cr = %CourseRegistration{id: cr_id},

Check warning on line 2702 in lib/cadet/assessments/assessments.ex

View workflow job for this annotation

GitHub Actions / Run CI

variable "cr_id" is unused (if the variable is not meant to be used, prefix it with an underscore)
last_modified_at,
force_submit
) do
Expand Down
3 changes: 2 additions & 1 deletion lib/cadet/assessments/question_types/programming_question.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ defmodule Cadet.Assessments.QuestionTypes.ProgrammingQuestion do
field(:template, :string)
field(:postpend, :string, default: "")
field(:solution, :string)
field(:llm_prompt, :string)
embeds_many(:public, Testcase)
embeds_many(:opaque, Testcase)
embeds_many(:secret, Testcase)
end

@required_fields ~w(content template)a
@optional_fields ~w(solution prepend postpend)a
@optional_fields ~w(solution prepend postpend llm_prompt)a

def changeset(question, params \\ %{}) do
question
Expand Down
54 changes: 52 additions & 2 deletions lib/cadet/courses/course.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ defmodule Cadet.Courses.Course do
enable_achievements: boolean(),
enable_sourcecast: boolean(),
enable_stories: boolean(),
enable_llm_grading: boolean(),
llm_api_key: String.t() | nil,
source_chapter: integer(),
source_variant: String.t(),
module_help_text: String.t(),
Expand All @@ -28,6 +30,8 @@ defmodule Cadet.Courses.Course do
field(:enable_achievements, :boolean, default: true)
field(:enable_sourcecast, :boolean, default: true)
field(:enable_stories, :boolean, default: false)
field(:enable_llm_grading, :boolean)
field(:llm_api_key, :string)
field(:source_chapter, :integer)
field(:source_variant, :string)
field(:module_help_text, :string)
Expand All @@ -42,13 +46,59 @@ defmodule Cadet.Courses.Course do

@required_fields ~w(course_name viewable enable_game
enable_achievements enable_sourcecast enable_stories source_chapter source_variant)a
@optional_fields ~w(course_short_name module_help_text)a

@optional_fields ~w(course_short_name module_help_text enable_llm_grading llm_api_key)a

@spec changeset(
{map(), map()}
| %{
:__struct__ => atom() | %{:__changeset__ => map(), optional(any()) => any()},
optional(atom()) => any()
},
%{optional(:__struct__) => none(), optional(atom() | binary()) => any()}
) :: Ecto.Changeset.t()
def changeset(course, params) do
course
|> cast(params, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
|> validate_sublanguage_combination(params)
|> put_encrypted_llm_api_key()
end

def put_encrypted_llm_api_key(changeset) do
if llm_api_key = get_change(changeset, :llm_api_key) do
if is_binary(llm_api_key) and llm_api_key != "" do
secret = Application.get_env(:openai, :encryption_key)

if is_binary(secret) and byte_size(secret) >= 16 do
# Use first 16 bytes for AES-128, 24 for AES-192, or 32 for AES-256
key = binary_part(secret, 0, min(32, byte_size(secret)))
# Use AES in GCM mode for encryption
iv = :crypto.strong_rand_bytes(16)

{ciphertext, tag} =
:crypto.crypto_one_time_aead(
:aes_gcm,
key,
iv,
llm_api_key,
"",
true
)

# Store both the IV, ciphertext and tag
encrypted = iv <> tag <> ciphertext
put_change(changeset, :llm_api_key, Base.encode64(encrypted))
else
add_error(changeset, :llm_api_key, "encryption key not configured properly")
end
else
# If empty string or nil is provided, don't encrypt but don't add error
changeset
end
else
# The key is not being changed, so we need to preserve the existing value
put_change(changeset, :llm_api_key, changeset.data.llm_api_key)
end
end

# Validates combination of Source chapter and variant
Expand Down
3 changes: 2 additions & 1 deletion lib/cadet/jobs/xml_parser.ex
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,8 @@ defmodule Cadet.Updater.XMLParser do
prepend: ~x"./SNIPPET/PREPEND/text()" |> transform_by(&process_charlist/1),
template: ~x"./SNIPPET/TEMPLATE/text()" |> transform_by(&process_charlist/1),
postpend: ~x"./SNIPPET/POSTPEND/text()" |> transform_by(&process_charlist/1),
solution: ~x"./SNIPPET/SOLUTION/text()" |> transform_by(&process_charlist/1)
solution: ~x"./SNIPPET/SOLUTION/text()" |> transform_by(&process_charlist/1),
llm_prompt: ~x"./LLM_GRADING_PROMPT/text()" |> transform_by(&process_charlist/1)
),
entity
|> xmap(
Expand Down
2 changes: 2 additions & 0 deletions lib/cadet_web/admin_controllers/admin_courses_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ defmodule CadetWeb.AdminCoursesController do
enable_achievements(:body, :boolean, "Enable achievements")
enable_sourcecast(:body, :boolean, "Enable sourcecast")
enable_stories(:body, :boolean, "Enable stories")
enable_llm_grading(:body, :boolean, "Enable LLM grading")
llm_api_key(:body, :string, "OpenAI API key for this course")
sublanguage(:body, Schema.ref(:AdminSublanguage), "sublanguage object")
module_help_text(:body, :string, "Module help text")
end
Expand Down
6 changes: 6 additions & 0 deletions lib/cadet_web/controllers/courses_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ defmodule CadetWeb.CoursesController do
enable_achievements(:body, :boolean, "Enable achievements", required: true)
enable_sourcecast(:body, :boolean, "Enable sourcecast", required: true)
enable_stories(:body, :boolean, "Enable stories", required: true)
enable_llm_grading(:body, :boolean, "Enable LLM grading", required: false)
llm_api_key(:body, :string, "OpenAI API key for this course", required: false)
source_chapter(:body, :number, "Default source chapter", required: true)

source_variant(:body, Schema.ref(:SourceVariant), "Default source variant name",
Expand Down Expand Up @@ -97,6 +99,8 @@ defmodule CadetWeb.CoursesController do
enable_achievements(:boolean, "Enable achievements", required: true)
enable_sourcecast(:boolean, "Enable sourcecast", required: true)
enable_stories(:boolean, "Enable stories", required: true)
enable_llm_grading(:boolean, "Enable LLM grading", required: false)
llm_api_key(:string, "OpenAI API key for this course", required: false)
source_chapter(:integer, "Source Chapter number from 1 to 4", required: true)
source_variant(Schema.ref(:SourceVariant), "Source Variant name", required: true)
module_help_text(:string, "Module help text", required: true)
Expand All @@ -111,6 +115,8 @@ defmodule CadetWeb.CoursesController do
enable_achievements: true,
enable_sourcecast: true,
enable_stories: false,
enable_llm_grading: false,
llm_api_key: "sk-1234567890",
source_chapter: 1,
source_variant: "default",
module_help_text: "Help text",
Expand Down
Loading
Loading