Skip to content

Commit 9c2823c

Browse files
xxdydxEugeneOYZ1203nTkaixiang
authored
AI-powered marking (#1248)
* feat: v1 of AI-generated comments * feat: added logging of inputs and outputs * Update generate_ai_comments.ex * feat: function to save outputs to database * Format answers json before sending to LLM * Add LLM Prompt to question params when submitting assessment xml file * Add LLM Prompt to api response when grading view is open * feat: added llm_prompt from qn to raw_prompt * feat: enabling/disabling of LLM feature by course level * feat: added llm_grading boolean field to course creation API * feat: added api key storage in courses & edit api key/enable llm grading * feat: encryption for llm_api_key * feat: added final comment editing route * feat: added logging of chosen comments * fix: bugs when certain fields were missing * feat: updated tests * formatting * fix: error handling when calling openai API * fix: credo issues * formatting * Address some comments * Fix formatting * rm IO.inspect * a * Use case instead of if * Streamlines generate_ai_comments to only send the selected question and its relevant info + use the correct llm_prompt * Remove unncessary field * default: false for llm_grading * Add proper linking between ai_comments table and submissions. Return it to submission retrieval as well * Resolve some migration comments * Add llm_model and llm_api_url to the DB + schema * Moves api key, api url, llm model and course prompt to course level * Add encryption_key to env * Do not hardcode formatting instructions * Add Assessment level prompts to the XML * Return some additional info for composing of prompts * Remove un-used 'save comments' * Fix existing assessment tests * Fix generate_ai_comments test cases * Fix bug preventing avengers from generating ai comments * Fix up tests + error msgs * Formatting * some mix credo suggestions * format * Fix credo issue * bug fix + credo fixes * Fix tests * format * Modify test.exs * Update lib/cadet_web/controllers/generate_ai_comments.ex Co-authored-by: Copilot <[email protected]> * Copilot feedback * format * Work on sentry comments * Fix type * Redate migrations to maintain total order * Add newline at EOF * Fix indent * Fix capitalization * Remove llmApiKey from any kind of storage on FE * Remove indexes * rm todo * rm todo * Re-format ai_comments to reference answer_id instead * Abstract out + remove un-used field * Add delimeter + bug fixes * Mix format * Switch to openAI module * rm un-used * Merge all prompts to :prompts to preserve abstraction * rm * Fix formatting * Fix test * Revert some dependency changes * Update actions versions * Improve encrypt + decrypt robustness * Fix dialyzer * Re-factor schema --------- Co-authored-by: Eugene Oh Yun Zheng <[email protected]> Co-authored-by: tkaixiang <[email protected]>
1 parent 2171677 commit 9c2823c

33 files changed

+986
-32
lines changed

.github/workflows/cd.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ jobs:
2929
OTP_VERSION: 27.3.3
3030
steps:
3131
- uses: rlespinasse/[email protected]
32-
- uses: actions/checkout@v4
32+
- uses: actions/checkout@v5
3333
- name: Cache deps
3434
uses: actions/cache@v4
3535
with:

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ jobs:
3737
# needed because the postgres container does not provide a healthcheck
3838
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
3939
steps:
40-
- uses: actions/checkout@v4
40+
- uses: actions/checkout@v5
4141
- name: Cache deps
4242
uses: actions/cache@v4
4343
with:

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,6 @@ erl_crash.dump
118118

119119
# Generated lexer
120120
/src/source_lexer.erl
121+
122+
# Ignore log files
123+
/log

config/dev.secrets.exs.example

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,13 +98,9 @@ config :cadet,
9898
# ws_endpoint_address: "ws://hostname:port"
9999
]
100100

101-
config :openai,
102-
# find it at https://platform.openai.com/account/api-keys
103-
api_key: "the actual api key",
104-
# For source academy deployment, leave this as empty string.Ingeneral could find it at https://platform.openai.com/account/org-settings under "Organization ID".
105-
organization_key: "",
106-
# optional, passed to [HTTPoison.Request](https://hexdocs.pm/httpoison/HTTPoison.Request.html) options
107-
http_options: [recv_timeout: 170_0000]
101+
config :openai,
102+
# Input your own AES-256 encryption key here for encrypting LLM API keys of 16, 24 or 32 bytes
103+
encryption_key: "<ADD YOUR OWN KEY HERE>"
108104

109105
# config :sentry,
110106
# dsn: "https://public_key/sentry.io/somethingsomething"

config/test.exs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,7 @@ config :cadet, Oban,
100100
testing: :manual
101101

102102
config :cadet, Cadet.Mailer, adapter: Bamboo.TestAdapter
103+
104+
config :openai,
105+
# Input your own AES-256 encryption key here for encrypting LLM API keys
106+
encryption_key: "b4u7g0AyN3Tu2br9WSdZQjLMQ8bed/wgQWrH2x3qPdW8D55iv10+ySgs+bxDirWE"

lib/cadet/ai_comments.ex

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
defmodule Cadet.AIComments do
2+
@moduledoc """
3+
Handles operations related to AI comments, including creation, updates, and retrieval.
4+
"""
5+
6+
import Ecto.Query
7+
alias Cadet.Repo
8+
alias Cadet.AIComments.AIComment
9+
10+
@doc """
11+
Creates a new AI comment log entry.
12+
"""
13+
def create_ai_comment(attrs \\ %{}) do
14+
%AIComment{}
15+
|> AIComment.changeset(attrs)
16+
|> Repo.insert()
17+
end
18+
19+
@doc """
20+
Gets an AI comment by ID.
21+
"""
22+
def get_ai_comment(id) do
23+
case Repo.get(AIComment, id) do
24+
nil -> {:error, :not_found}
25+
comment -> {:ok, comment}
26+
end
27+
end
28+
29+
@doc """
30+
Retrieves the latest AI comment for a specific submission and question.
31+
Returns `nil` if no comment exists.
32+
"""
33+
def get_latest_ai_comment(answer_id) do
34+
Repo.one(
35+
from(c in AIComment,
36+
where: c.answer_id == ^answer_id,
37+
order_by: [desc: c.inserted_at],
38+
limit: 1
39+
)
40+
)
41+
end
42+
43+
@doc """
44+
Updates the final comment for a specific submission and question.
45+
Returns the most recent comment entry for that submission/question.
46+
"""
47+
def update_final_comment(answer_id, final_comment) do
48+
comment = get_latest_ai_comment(answer_id)
49+
50+
case comment do
51+
nil ->
52+
{:error, :not_found}
53+
54+
_ ->
55+
comment
56+
|> AIComment.changeset(%{final_comment: final_comment})
57+
|> Repo.update()
58+
end
59+
end
60+
61+
@doc """
62+
Updates an existing AI comment with new attributes.
63+
"""
64+
def update_ai_comment(id, attrs) do
65+
id
66+
|> get_ai_comment()
67+
|> case do
68+
{:error, :not_found} ->
69+
{:error, :not_found}
70+
71+
{:ok, comment} ->
72+
comment
73+
|> AIComment.changeset(attrs)
74+
|> Repo.update()
75+
end
76+
end
77+
end
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
defmodule Cadet.AIComments.AIComment do
2+
@moduledoc """
3+
Defines the schema and changeset for AI comments.
4+
"""
5+
6+
use Ecto.Schema
7+
import Ecto.Changeset
8+
9+
schema "ai_comment_logs" do
10+
field(:raw_prompt, :string)
11+
field(:answers_json, :string)
12+
field(:response, :string)
13+
field(:error, :string)
14+
field(:final_comment, :string)
15+
16+
belongs_to(:answer, Cadet.Assessments.Answer)
17+
18+
timestamps()
19+
end
20+
21+
@required_fields ~w(answer_id raw_prompt answers_json)a
22+
@optional_fields ~w(response error final_comment)a
23+
24+
def changeset(ai_comment, attrs) do
25+
ai_comment
26+
|> cast(attrs, @required_fields ++ @optional_fields)
27+
|> validate_required(@required_fields)
28+
|> foreign_key_constraint(:answer_id)
29+
end
30+
end

lib/cadet/assessments/answer.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ defmodule Cadet.Assessments.Answer do
1010
alias Cadet.Assessments.Answer.AutogradingStatus
1111
alias Cadet.Assessments.AnswerTypes.{MCQAnswer, ProgrammingAnswer, VotingAnswer}
1212
alias Cadet.Assessments.{Question, QuestionType, Submission}
13+
alias Cadet.AIComments.AIComment
1314

1415
@type t :: %__MODULE__{}
1516

@@ -29,6 +30,7 @@ defmodule Cadet.Assessments.Answer do
2930
belongs_to(:grader, CourseRegistration)
3031
belongs_to(:submission, Submission)
3132
belongs_to(:question, Question)
33+
has_many(:ai_comments, AIComment, on_delete: :delete_all)
3234

3335
timestamps()
3436
end

lib/cadet/assessments/assessment.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ defmodule Cadet.Assessments.Assessment do
3636
field(:max_team_size, :integer, default: 1)
3737
field(:has_token_counter, :boolean, default: false)
3838
field(:has_voting_features, :boolean, default: false)
39+
field(:llm_assessment_prompt, :string, default: nil)
3940

4041
belongs_to(:config, AssessmentConfig)
4142
belongs_to(:course, Course)
@@ -46,7 +47,7 @@ defmodule Cadet.Assessments.Assessment do
4647

4748
@required_fields ~w(title open_at close_at number course_id config_id max_team_size)a
4849
@optional_fields ~w(reading summary_short summary_long
49-
is_published story cover_picture access password has_token_counter has_voting_features)a
50+
is_published story cover_picture access password has_token_counter has_voting_features llm_assessment_prompt)a
5051
@optional_file_fields ~w(mission_pdf)a
5152

5253
def changeset(assessment, params) do

lib/cadet/assessments/assessments.ex

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3043,13 +3043,60 @@ defmodule Cadet.Assessments do
30433043
}
30443044
end
30453045

3046+
@spec get_answer(integer() | String.t()) ::
3047+
{:ok, Answer.t()} | {:error, {:bad_request, String.t()}}
3048+
def get_answer(id) when is_ecto_id(id) do
3049+
answer =
3050+
Answer
3051+
|> where(id: ^id)
3052+
# [a] are bindings (in SQL it is similar to FROM answers "AS a"),
3053+
# this line's alias is INNER JOIN ... "AS q"
3054+
|> join(:inner, [a], q in assoc(a, :question))
3055+
|> join(:inner, [_, q], ast in assoc(q, :assessment))
3056+
|> join(:inner, [..., ast], ac in assoc(ast, :config))
3057+
|> join(:left, [a, ...], g in assoc(a, :grader))
3058+
|> join(:left, [_, ..., g], gu in assoc(g, :user))
3059+
|> join(:inner, [a, ...], s in assoc(a, :submission))
3060+
|> join(:left, [_, ..., s], st in assoc(s, :student))
3061+
|> join(:left, [..., st], u in assoc(st, :user))
3062+
|> join(:left, [..., s, _, _], t in assoc(s, :team))
3063+
|> join(:left, [..., t], tm in assoc(t, :team_members))
3064+
|> join(:left, [..., tm], tms in assoc(tm, :student))
3065+
|> join(:left, [..., tms], tmu in assoc(tms, :user))
3066+
|> join(:left, [a, ...], ai in assoc(a, :ai_comments))
3067+
|> preload([_, q, ast, ac, g, gu, s, st, u, t, tm, tms, tmu, ai],
3068+
ai_comments: ai,
3069+
question: {q, assessment: {ast, config: ac}},
3070+
grader: {g, user: gu},
3071+
submission:
3072+
{s, student: {st, user: u}, team: {t, team_members: {tm, student: {tms, user: tmu}}}}
3073+
)
3074+
|> Repo.one()
3075+
3076+
if is_nil(answer) do
3077+
{:error, {:bad_request, "Answer not found."}}
3078+
else
3079+
if answer.question.type == :voting do
3080+
empty_contest_entries = Map.put(answer.question.question, :contest_entries, [])
3081+
empty_popular_leaderboard = Map.put(empty_contest_entries, :popular_leaderboard, [])
3082+
empty_contest_leaderboard = Map.put(empty_popular_leaderboard, :contest_leaderboard, [])
3083+
question = Map.put(answer.question, :question, empty_contest_leaderboard)
3084+
Map.put(answer, :question, question)
3085+
end
3086+
3087+
{:ok, answer}
3088+
end
3089+
end
3090+
30463091
@spec get_answers_in_submission(integer() | String.t()) ::
30473092
{:ok, {[Answer.t()], Assessment.t()}}
30483093
| {:error, {:bad_request, String.t()}}
30493094
def get_answers_in_submission(id) when is_ecto_id(id) do
3050-
answer_query =
3095+
base_query =
30513096
Answer
30523097
|> where(submission_id: ^id)
3098+
# [a] are bindings (in SQL it is similar to FROM answers "AS a"),
3099+
# this line's alias is INNER JOIN ... "AS q"
30533100
|> join(:inner, [a], q in assoc(a, :question))
30543101
|> join(:inner, [_, q], ast in assoc(q, :assessment))
30553102
|> join(:inner, [..., ast], ac in assoc(ast, :config))
@@ -3062,15 +3109,17 @@ defmodule Cadet.Assessments do
30623109
|> join(:left, [..., t], tm in assoc(t, :team_members))
30633110
|> join(:left, [..., tm], tms in assoc(tm, :student))
30643111
|> join(:left, [..., tms], tmu in assoc(tms, :user))
3065-
|> preload([_, q, ast, ac, g, gu, s, st, u, t, tm, tms, tmu],
3112+
|> join(:left, [a, ...], ai in assoc(a, :ai_comments))
3113+
|> preload([_, q, ast, ac, g, gu, s, st, u, t, tm, tms, tmu, ai],
30663114
question: {q, assessment: {ast, config: ac}},
30673115
grader: {g, user: gu},
30683116
submission:
3069-
{s, student: {st, user: u}, team: {t, team_members: {tm, student: {tms, user: tmu}}}}
3117+
{s, student: {st, user: u}, team: {t, team_members: {tm, student: {tms, user: tmu}}}},
3118+
ai_comments: ai
30703119
)
30713120

30723121
answers =
3073-
answer_query
3122+
base_query
30743123
|> Repo.all()
30753124
|> Enum.sort_by(& &1.question.display_order)
30763125
|> Enum.map(fn ans ->
@@ -3544,4 +3593,15 @@ defmodule Cadet.Assessments do
35443593
end)
35453594
end
35463595
end
3596+
3597+
def get_llm_assessment_prompt(question_id) do
3598+
query =
3599+
from(q in Question,
3600+
where: q.id == ^question_id,
3601+
join: a in assoc(q, :assessment),
3602+
select: a.llm_assessment_prompt
3603+
)
3604+
3605+
Repo.one(query)
3606+
end
35473607
end

0 commit comments

Comments
 (0)