Skip to content
Open
Show file tree
Hide file tree
Changes from 56 commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
864560d
feat(ai): implement versioning and finalization for AI comments
tzj04 Feb 25, 2026
88593a1
feat(ai_comments): add comment selection and word-level version tracking
tzj04 Feb 25, 2026
3190d3e
dependencies
leongyiquan Feb 25, 2026
025938d
dependencies
tzj04 Feb 25, 2026
64c1bcd
Merge remote-tracking branch 'yiquan/ai-button-improvements' into fea…
tzj04 Feb 25, 2026
30ad398
feat: add LLM usage logs and feedback schema
tzj04 Mar 3, 2026
dcd39c2
feat: implement LLM stats context and models
tzj04 Mar 3, 2026
2f00d6b
feat: add admin controller for LLM stats management
tzj04 Mar 3, 2026
4668677
feat: integrate LLM stats tracking into assessments
tzj04 Mar 3, 2026
ce5769d
feat: add LLM stats routes and views
tzj04 Mar 3, 2026
aaf6a26
feat: implement AI comment generation controller
tzj04 Mar 3, 2026
9a869f4
refactor(ai-comments): remove redundant final_comment endpoint and co…
tzj04 Mar 16, 2026
7fc9ba6
refactor(ai-comments): drop myers diff generation and diff columns fr…
tzj04 Mar 16, 2026
1274299
Stop tracking config/cadet.exs
tzj04 Mar 17, 2026
a999538
fix: Potential crash if a non-numeric key is provided
tzj04 Mar 17, 2026
3529738
Potential fix for pull request finding
tzj04 Mar 17, 2026
d253c61
fix: persist and restore AI comment selections correctly
tzj04 Mar 17, 2026
d279faa
fix: prevent race condition in create_comment_version using advisory …
tzj04 Mar 17, 2026
284939d
test: update assessment controller expectations for isLlmGraded field
tzj04 Mar 17, 2026
4ab4e78
refactor(migrations): fold ai_comment_versions diff-column removal in…
tzj04 Mar 17, 2026
fd9f615
fix: swagger generation
tzj04 Mar 17, 2026
d5a513b
Validate and safely parse answer_id in save_chosen_comments to preven…
tzj04 Mar 17, 2026
2370ab4
Merge branch 'master' into feat/ai-comment-workflow
tzj04 Mar 17, 2026
394302e
Merge branch 'feat/ai-comment-workflow' of https://github.com/tzj04/S…
tzj04 Mar 17, 2026
c102625
refactor(deps): migrate arc/arc_ecto to waffle/waffle_ecto
tzj04 Mar 17, 2026
23cefca
Fix: isLlmGraded to ignore empty assessment prompts
tzj04 Mar 17, 2026
5d89995
test(assessments): add has_llm_questions aggregation coverage
tzj04 Mar 17, 2026
85a2682
test(llm-stats): add coverage for usage logging, stats aggregation, a…
tzj04 Mar 17, 2026
a4eb931
test(admin): add request tests for LLM stats and feedback endpoints
tzj04 Mar 17, 2026
eddad92
Fix: Handle LLM usage log insert failures in AI comments controller
tzj04 Mar 17, 2026
7dfed65
Fix: formatting issues
tzj04 Mar 17, 2026
7eddeb0
Fix: more formatting issues
tzj04 Mar 17, 2026
b2b6fce
fix: remove unreachable parse_answer_id fallback to satisfy Dialyzer
tzj04 Mar 17, 2026
56b9cb6
Fix: save_chosen_comments order to validate edits before persisting s…
tzj04 Mar 17, 2026
657383d
Fix: formatting issue
tzj04 Mar 17, 2026
4556433
feat: implement AI token cost tracking and stats UI
leongyiquan Mar 20, 2026
0083876
chore: save remaining backend assessments and migration files
leongyiquan Mar 20, 2026
6cdbed4
refactor: reduce cyclomatic complexity in LLM cost calculator
leongyiquan Mar 20, 2026
a9dd2f3
pass testcases for llmcost calculations
leongyiquan Mar 21, 2026
425fd63
pass testcases for llmcost calculations
leongyiquan Mar 21, 2026
fa939fb
pass testcases for llmcost calculations
leongyiquan Mar 21, 2026
4dd4ed3
pass testcases for llmcost calculations
leongyiquan Mar 21, 2026
261e799
pass testcases for llmcost calculations
leongyiquan Mar 21, 2026
17670d5
pass testcases for llmcost calculations
leongyiquan Mar 26, 2026
cadf106
pass testcases for llmcost calculations
leongyiquan Mar 26, 2026
1c7fa09
pass testcases for llmcost calculations
leongyiquan Mar 26, 2026
970cda2
pass testcases for llmcost calculations
leongyiquan Mar 26, 2026
73f5869
pass testcases for llmcost calculations
leongyiquan Mar 26, 2026
058b836
pass testcases for llmcost calculations
leongyiquan Mar 26, 2026
e766b82
pass testcases for llmcost calculations
leongyiquan Mar 26, 2026
bd8a82b
pass testcases for llmcost calculations
leongyiquan Mar 26, 2026
70c50ff
pass testcases for llmcost calculations
leongyiquan Mar 27, 2026
296cd17
pass testcases for llmcost calculations
leongyiquan Mar 27, 2026
4da9d4f
pass testcases for llmcost calculations
leongyiquan Mar 27, 2026
bcf078b
Added a new tab for coursewide summary for LLM stats
leongyiquan Mar 28, 2026
a9d1a8b
fixed possible type mismatch
leongyiquan Mar 28, 2026
c011c04
fixed potential silent data logging failure
leongyiquan Mar 28, 2026
304e633
fixed source errors
leongyiquan Mar 28, 2026
9721dc7
fixed MCQ question embed handling
leongyiquan Mar 28, 2026
0f4a7d4
fix invalid cours id crash path
leongyiquan Mar 28, 2026
043280d
fixed formatting
leongyiquan Mar 28, 2026
40f23a9
fixed ai code analysis test file
leongyiquan Mar 28, 2026
1403530
fixed pattern match error
leongyiquan Mar 28, 2026
c81065a
added has_llm_content to assessment
leongyiquan Mar 28, 2026
0755dc3
fixing coverage for string course ID
leongyiquan Mar 28, 2026
ab7d85a
fixing public LLMStats functions
leongyiquan Mar 28, 2026
0e9c429
fixed readability
leongyiquan Mar 28, 2026
7fe996a
fix(xml-parser): support LLM_QUESTION_PROMPT and legacy LLM_GRADING_P…
tzj04 Mar 30, 2026
f0b6c82
fix(grading): require course enable plus mission and task prompts bef…
tzj04 Mar 30, 2026
70d9e03
fix: formatting
tzj04 Mar 30, 2026
121d864
fix: credo alias
tzj04 Mar 30, 2026
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ erl_crash.dump
# variables.
/config/*secrets.exs

/config/cadet.exs

# Uploaded file
/cs1101s
/uploads
Expand Down
6 changes: 3 additions & 3 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,10 @@ config :ex_aws,

config :ex_aws, :hackney_opts, recv_timeout: 660_000

# Configure Arc File Upload
config :arc, virtual_host: true
# Configure Waffle File Upload
config :waffle, virtual_host: true
# Or uncomment below to use local storage
# config :arc, storage: Arc.Storage.Local
# config :waffle, storage: Waffle.Storage.Local

# Configures Sentry
config :sentry,
Expand Down
9 changes: 9 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Config

# This file is executed after the code compilation on all environments.
# It contains runtime configuration that's evaluated when the system starts.

# Configure the port from environment variable if set
if port = System.get_env("PORT") do
config :cadet, CadetWeb.Endpoint, http: [:inet6, port: String.to_integer(port)]
end
2 changes: 1 addition & 1 deletion config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ config :cadet,
client_role_arn: "test"
]

config :arc, storage: Arc.Storage.Local
config :waffle, storage: Waffle.Storage.Local

if "test.secrets.exs" |> Path.expand(__DIR__) |> File.exists?(),
do: import_config("test.secrets.exs")
Expand Down
4 changes: 2 additions & 2 deletions lib/cadet.ex
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ defmodule Cadet do

def remote_assets do
quote do
use Arc.Definition
use Arc.Ecto.Definition
use Waffle.Definition
use Waffle.Ecto.Definition
end
end
end
105 changes: 87 additions & 18 deletions lib/cadet/ai_comments.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ defmodule Cadet.AIComments do

import Ecto.Query
alias Cadet.Repo
alias Cadet.AIComments.AIComment
alias Cadet.AIComments.{AIComment, AICommentVersion}

@doc """
Creates a new AI comment log entry.
Expand Down Expand Up @@ -41,37 +41,106 @@ defmodule Cadet.AIComments do
end

@doc """
Updates the final comment for a specific submission and question.
Returns the most recent comment entry for that submission/question.
Updates an existing AI comment with new attributes.
"""
def update_final_comment(answer_id, final_comment) do
comment = get_latest_ai_comment(answer_id)

case comment do
nil ->
def update_ai_comment(id, attrs) do
id
|> get_ai_comment()
|> case do
{:error, :not_found} ->
{:error, :not_found}

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

@doc """
Updates an existing AI comment with new attributes.
Saves selected comment indices and finalization metadata for an AI comment.
"""
def update_ai_comment(id, attrs) do
id
|> get_ai_comment()
|> case do
{:error, :not_found} ->
def save_selected_comments(answer_id, selected_indices, finalized_by_id) do
case get_latest_ai_comment(answer_id) do
nil ->
{:error, :not_found}

{:ok, comment} ->
comment ->
comment
|> AIComment.changeset(attrs)
|> AIComment.changeset(%{
selected_indices: selected_indices,
finalized_by_id: finalized_by_id,
finalized_at: DateTime.truncate(DateTime.utc_now(), :second)
})
|> Repo.update()
end
end

@doc """
Creates a new version entry for a specific comment index.
Automatically determines the next version number.
"""
def create_comment_version(ai_comment_id, comment_index, content, editor_id) do
transaction_result =
Repo.transaction(fn ->
# Serialize version creation per (ai_comment_id, comment_index)
# to avoid duplicate version numbers.
case Repo.query("SELECT pg_advisory_xact_lock($1, $2)", [ai_comment_id, comment_index]) do
{:ok, _} ->
next_version =
Repo.one(
from(v in AICommentVersion,
where: v.ai_comment_id == ^ai_comment_id and v.comment_index == ^comment_index,
select: coalesce(max(v.version_number), 0)
)
) + 1

case %AICommentVersion{}
|> AICommentVersion.changeset(%{
ai_comment_id: ai_comment_id,
comment_index: comment_index,
version_number: next_version,
content: content,
editor_id: editor_id
})
|> Repo.insert() do
{:ok, version} -> version
{:error, changeset} -> Repo.rollback(changeset)
end

{:error, error} ->
Repo.rollback(error)
end
end)

case transaction_result do
{:ok, version} -> {:ok, version}
{:error, reason} -> {:error, reason}
end
end

@doc """
Gets all versions for a specific AI comment, ordered by comment_index and version_number.
"""
def get_comment_versions(ai_comment_id) do
Repo.all(
from(v in AICommentVersion,
where: v.ai_comment_id == ^ai_comment_id,
order_by: [asc: v.comment_index, asc: v.version_number]
)
)
end

@doc """
Gets the latest version for a specific comment index.
"""
def get_latest_version(ai_comment_id, comment_index) do
Repo.one(
from(v in AICommentVersion,
where: v.ai_comment_id == ^ai_comment_id and v.comment_index == ^comment_index,
order_by: [desc: v.version_number],
limit: 1
)
)
end
end
8 changes: 6 additions & 2 deletions lib/cadet/ai_comments/ai_comment.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,19 @@ defmodule Cadet.AIComments.AIComment do
field(:answers_json, :string)
field(:response, :string)
field(:error, :string)
field(:final_comment, :string)
field(:selected_indices, {:array, :integer})
field(:finalized_at, :utc_datetime)

belongs_to(:answer, Cadet.Assessments.Answer)
belongs_to(:finalized_by, Cadet.Accounts.User, foreign_key: :finalized_by_id)

has_many(:versions, Cadet.AIComments.AICommentVersion)

timestamps()
end

@required_fields ~w(answer_id raw_prompt answers_json)a
@optional_fields ~w(response error final_comment)a
@optional_fields ~w(response error selected_indices finalized_by_id finalized_at)a

def changeset(ai_comment, attrs) do
ai_comment
Expand Down
32 changes: 32 additions & 0 deletions lib/cadet/ai_comments/ai_comment_version.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
defmodule Cadet.AIComments.AICommentVersion do
@moduledoc """
Defines the schema and changeset for AI comment versions.
Tracks per-comment edits made by tutors.
"""

use Ecto.Schema
import Ecto.Changeset

schema "ai_comment_versions" do
field(:comment_index, :integer)
field(:version_number, :integer)
field(:content, :string)

belongs_to(:ai_comment, Cadet.AIComments.AIComment)
belongs_to(:editor, Cadet.Accounts.User, foreign_key: :editor_id)

timestamps()
end

@required_fields ~w(ai_comment_id comment_index version_number content)a
@optional_fields ~w(editor_id)a

def changeset(version, attrs) do
version
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
|> foreign_key_constraint(:ai_comment_id)
|> foreign_key_constraint(:editor_id)
|> unique_constraint([:ai_comment_id, :comment_index, :version_number])
end
end
34 changes: 30 additions & 4 deletions lib/cadet/assessments/assessment.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule Cadet.Assessments.Assessment do
(mission, sidequest, path, and contest)
"""
use Cadet, :model
use Arc.Ecto.Schema
use Waffle.Ecto.Schema

alias Cadet.Repo
alias Cadet.Assessments.{AssessmentAccess, Question, SubmissionStatus, Upload}
Expand All @@ -21,6 +21,7 @@ defmodule Cadet.Assessments.Assessment do
field(:question_count, :integer, virtual: true)
field(:graded_count, :integer, virtual: true)
field(:is_grading_published, :boolean, virtual: true)
field(:has_llm_questions, :boolean, virtual: true, default: false)
field(:title, :string)
field(:is_published, :boolean, default: false)
field(:summary_short, :string)
Expand All @@ -37,6 +38,12 @@ defmodule Cadet.Assessments.Assessment do
field(:has_token_counter, :boolean, default: false)
field(:has_voting_features, :boolean, default: false)
field(:llm_assessment_prompt, :string, default: nil)
field(:llm_input_cost, :decimal)
field(:llm_output_cost, :decimal)
field(:llm_total_input_tokens, :integer, default: 0)
field(:llm_total_output_tokens, :integer, default: 0)
field(:llm_total_cached_tokens, :integer, default: 0)
field(:llm_total_cost, :decimal, default: Decimal.new("0.0"))

belongs_to(:config, AssessmentConfig)
belongs_to(:course, Course)
Expand All @@ -47,7 +54,10 @@ defmodule Cadet.Assessments.Assessment do

@required_fields ~w(title open_at close_at number course_id config_id max_team_size)a
@optional_fields ~w(reading summary_short summary_long
is_published story cover_picture access password has_token_counter has_voting_features llm_assessment_prompt)a
is_published story cover_picture access password has_token_counter
has_voting_features llm_assessment_prompt
llm_input_cost llm_output_cost llm_total_input_tokens
llm_total_output_tokens llm_total_cached_tokens llm_total_cost)a
@optional_file_fields ~w(mission_pdf)a

def changeset(assessment, params) do
Expand All @@ -60,12 +70,14 @@ defmodule Cadet.Assessments.Assessment do
|> cast_attachments(params, @optional_file_fields)
|> cast(params, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
# ADD THIS LINE HERE to apply your defaults
|> put_default_costs()
|> add_belongs_to_id_from_model([:config, :course], params)
|> foreign_key_constraint(:config_id)
|> foreign_key_constraint(:course_id)
|> unique_constraint([:number, :course_id])
|> validate_config_course
|> validate_open_close_date
|> validate_config_course()
|> validate_open_close_date()
|> validate_number(:max_team_size, greater_than_or_equal_to: 1)
end

Expand All @@ -86,6 +98,20 @@ defmodule Cadet.Assessments.Assessment do
end
end

defp put_default_costs(changeset) do
changeset
|> put_fallback(:llm_input_cost, Decimal.new("3.20"))
|> put_fallback(:llm_output_cost, Decimal.new("12.80"))
end

defp put_fallback(changeset, field, default_val) do
if get_field(changeset, field) == nil do
put_change(changeset, field, default_val)
else
changeset
end
end

defp validate_open_close_date(changeset) do
validate_change(changeset, :open_at, fn :open_at, open_at ->
if Timex.before?(open_at, get_field(changeset, :close_at)) do
Expand Down
Loading
Loading