Skip to content
Open
Show file tree
Hide file tree
Changes from 13 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
4 changes: 4 additions & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
elixir 1.19.0-otp-27
erlang 27.2.4
local 1.19.0-otp-27
exlixir 1.19.0-otp-27
179 changes: 179 additions & 0 deletions config/cadet.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# Example production configuration file

import Config

# See https://hexdocs.pm/phoenix/Phoenix.Endpoint.html#module-runtime-configuration
# except for cors_endpoints, load_from_system_env which are custom
config :cadet, CadetWeb.Endpoint,
# See https://hexdocs.pm/corsica/Corsica.html#module-origins
# Remove for "*"
cors_endpoints: "example.com",
server: true,
# If true, expects an environment variable PORT specifying the port to listen on
load_from_system_env: true,
url: [host: "api.example.com", port: 80],
# You can specify the port here instead
# e.g http: [compress: true, port: 4000]
http: [compress: true],
# Generate using `mix phx.gen.secret`
secret_key_base: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"

config :cadet, Cadet.Auth.Guardian,
issuer: "cadet",
# Generate using `mix phx.gen.secret`
secret_key: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"

config :cadet, Cadet.Repo,
# Do not change this, only Postgres is supported
# (This is here because of how configuration works in Elixir.)
adapter: Ecto.Adapters.Postgres,
# The AWS Secrets Manager secret name containing the database connection details
rds_secret_name: "<unique-identifier>-cadet-db",
# Alternatively, you can include the credentials here:
# (comment out or remove rds_secret_name)
# username: "postgres",
# password: "postgres",
# database: "cadet_stg",
# hostname: "localhost",
pool_size: 10

config :cadet,
identity_providers: %{
# # To use authentication with ADFS.
# "nusnet" =>
# {Cadet.Auth.Providers.ADFS,
# %{
# # The OAuth2 token endpoint.
# token_endpoint: "https://my-adfs/adfs/oauth2/token"
# }},
# # An example of OpenID authentication with Cognito. Any OpenID-compliant
# # provider should work.
# "cognito" =>
# {Cadet.Auth.Providers.OpenID,
# %{
# # This should match a key in openid_connect_providers below
# openid_provider: :cognito,
# # You may need to write your own claim extractor for other providers
# claim_extractor: Cadet.Auth.Providers.CognitoClaimExtractor
# }},

# # Example SAML authentication with NUS Student IdP
# "test_saml" =>
# {Cadet.Auth.Providers.SAML,
# %{
# assertion_extractor: Cadet.Auth.Providers.NusstuAssertionExtractor,
# client_redirect_url: "http://cadet.frontend:8000/login/callback"
# }},

"test" =>
{Cadet.Auth.Providers.Config,
[
%{
token: "admin_token",
code: "admin_code",
name: "Test Admin",
username: "admin",
role: :admin
},
%{
token: "staff_token",
code: "staff_code",
name: "Test Staff",
username: "staff",
role: :staff
},
%{
token: "student_token",
code: "student_code",
name: "Test Student",
username: "student",
role: :student
}
]}
},
# See https://hexdocs.pm/openid_connect/readme.html
# openid_connect_providers: [
# cognito: [
# discovery_document_uri: "",
# client_id: "",
# client_secret: "",
# response_type: "code",
# scope: "openid profile"
# ]
# ],
autograder: [
lambda_name: "<unique-identifier>-grader"
],
uploader: [
assets_bucket: "<unique-identifier>-assets",
assets_prefix: "courses/",
sourcecasts_bucket: "<unique-identifier>-cadet-sourcecasts"
],
# Configuration for Sling integration (executing on remote devices)
remote_execution: [
# Prefix for AWS IoT thing names
thing_prefix: "<unique-identifier>-sling",
# AWS IoT thing group to put created things into (must be set-up beforehand)
thing_group: "<unique-identifier>-sling",
# Role ARN to use when generating signed client MQTT-WS URLs (must be set-up beforehand)
# Note, you need to specify the correct account ID. Find it in AWS IAM at the bottom left.
client_role_arn: "arn:aws:iam::<account-id>:role/<unique-identifier>-cadet-frontend"
]

# Sentry DSN. This is only really useful to the NUS SoC deployments, but you can
# specify a DSN here if you wish to track backend errors.
# config :sentry,
# dsn: "https://public_key/sentry.io/somethingsomething"

# If you are not running on EC2, you will need to configure an AWS access token
# for the backend to access AWS resources:
#
# This will make ExAWS read the values from the corresponding environment variables.
# config :ex_aws,
# access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}],
# secret_access_key: [{:system, "AWS_SECRET_ACCESS_KEY"}]
#
# You can also just specify the values directly:
# config :ex_aws,
# access_key_id: "ACCESS KEY",
# secret_access_key: "SECRET KEY"
#
# You may also want to change the AWS or S3 region used for all resources:
# (Note, the default is ap-southeast-1 i.e. Singapore)
# config :ex_aws,
# region: "ap-southeast-1",
# s3: [
# scheme: "https://",
# host: "s3.ap-southeast-1.amazonaws.com",
# region: "ap-southeast-1"
# ]

# You may also want to change the timezone used for scheduled jobs
# config :cadet, Cadet.Jobs.Scheduler,
# timezone: "Asia/Singapore",

# # Additional configuration for SAML authentication
# # For more details, see https://github.com/handnot2/samly
# config :samly, Samly.Provider,
# idp_id_from: :path_segment,
# service_providers: [
# %{
# id: "source-academy-backend",
# certfile: "example_path/certfile.pem",
# keyfile: "example_path/keyfile.pem"
# }
# ],
# identity_providers: [
# %{
# id: "student",
# sp_id: "source-academy-backend",
# base_url: "https://example_backend/sso",
# metadata_file: "student_idp_metadata.xml"
# },
# %{
# id: "staff",
# sp_id: "source-academy-backend",
# base_url: "https://example_backend/sso",
# metadata_file: "staff_idp_metadata.xml"
# }
# ]
86 changes: 68 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,87 @@ 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
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

%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()
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
1 change: 1 addition & 0 deletions lib/cadet/assessments/assessment.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 Down
7 changes: 5 additions & 2 deletions lib/cadet/assessments/query.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ defmodule Cadet.Assessments.Query do
|> select([a, q], %Assessment{
a
| max_xp: q.max_xp,
question_count: q.question_count
question_count: q.question_count,
has_llm_questions: q.has_llm_questions
})
end

Expand Down Expand Up @@ -49,7 +50,9 @@ defmodule Cadet.Assessments.Query do
|> select([q], %{
assessment_id: q.assessment_id,
max_xp: sum(q.max_xp),
question_count: count(q.id)
question_count: count(q.id),
has_llm_questions:
fragment("bool_or(? ->> 'llm_prompt' IS NOT NULL AND ? ->> 'llm_prompt' != '')", q.question, q.question)
})
end
end
Loading
Loading