• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

source-academy / backend / 9c2823cf899c9314fd4ccec9dd6c3b589d83e839

04 Dec 2025 05:47PM UTC coverage: 88.716% (-0.9%) from 89.621%
9c2823cf899c9314fd4ccec9dd6c3b589d83e839

push

github

web-flow
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/gener... (continued)

118 of 174 new or added lines in 9 files covered. (67.82%)

1 existing line in 1 file now uncovered.

3758 of 4236 relevant lines covered (88.72%)

7103.93 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.45
/lib/cadet_web/admin_controllers/admin_grading_controller.ex
1
defmodule CadetWeb.AdminGradingController do
2
  use CadetWeb, :controller
3
  use PhoenixSwagger
4

5
  alias Cadet.{Assessments, Courses}
6

7
  @doc """
8
  # Query Parameters
9
  - `pageSize`: Integer. The number of submissions to return. Default 10.
10
  - `offset`: Integer. The number of submissions to skip. Default 0.
11
  - `title`: String. Assessment title.
12
  - `status`: String. Submission status.
13
  - `isFullyGraded`: Boolean. Whether the submission is fully graded.
14
  - `isGradingPublished`: Boolean. Whether the grading is published.
15
  - `group`: Boolean. Only the groups under the grader should be returned.
16
  - `groupName`: String. Group name.
17
  - `name`: String. User name.
18
  - `username`: String. User username.
19
  - `type`: String. Assessment Config type.
20
  - `isManuallyGraded`: Boolean. Whether the assessment is manually graded.
21
  """
22
  def index(conn, %{"group" => group} = params)
23
      when group in ["true", "false"] do
24
    course_reg = conn.assigns[:course_reg]
5✔
25

26
    boolean_params = [:is_fully_graded, :group, :is_manually_graded]
5✔
27
    int_params = [:page_size, :offset]
5✔
28

29
    # Convert string keys to atoms and parse values
30
    params =
5✔
31
      params
32
      |> to_snake_case_atom_keys()
33
      |> Map.put_new(:page_size, "10")
34
      |> Map.put_new(:offset, "0")
35

36
    filtered_boolean_params =
5✔
37
      params
38
      |> Map.take(boolean_params)
39
      |> Map.keys()
40

41
    params =
5✔
42
      params
43
      |> process_map_booleans(filtered_boolean_params)
44
      |> process_map_integers(int_params)
45
      |> Assessments.parse_sort_direction()
46
      |> Assessments.parse_sort_by()
47

48
    case Assessments.submissions_by_grader_for_index(course_reg, params) do
5✔
49
      {:ok, view_model} ->
50
        conn
51
        |> put_status(:ok)
52
        |> put_resp_content_type("application/json")
53
        |> render("gradingsummaries.json", view_model)
5✔
54
    end
55
  end
56

57
  def index(conn, _) do
58
    index(conn, %{"group" => "false"})
2✔
59
  end
60

61
  def index_all_submissions(conn, _) do
62
    index(
×
63
      conn,
64
      %{
65
        "group" => "false",
66
        "pageSize" => "100000000000",
67
        "offset" => "0"
68
      }
69
    )
70
  end
71

72
  def show(conn, %{"submissionid" => submission_id}) when is_ecto_id(submission_id) do
73
    case Assessments.get_answers_in_submission(submission_id) do
4✔
74
      {:ok, {answers, assessment}} ->
75
        case Courses.get_course_config(assessment.course_id) do
2✔
76
          {:ok, course} ->
77
            render(conn, "show.json", course: course, answers: answers, assessment: assessment)
2✔
78

79
          {:error, {status, message}} ->
80
            conn
81
            |> put_status(status)
NEW
82
            |> text(message)
×
83
        end
84

85
      {:error, {status, message}} ->
86
        conn
87
        |> put_status(status)
88
        |> text(message)
2✔
89
    end
90
  end
91

92
  def update(
93
        conn,
94
        %{
95
          "submissionid" => submission_id,
96
          "questionid" => question_id,
97
          "grading" => raw_grading
98
        }
99
      )
100
      when is_ecto_id(submission_id) and is_ecto_id(question_id) do
101
    course_reg = conn.assigns[:course_reg]
4✔
102

103
    grading = raw_grading |> snake_casify_string_keys()
4✔
104

105
    case Assessments.update_grading_info(
4✔
106
           %{submission_id: submission_id, question_id: question_id},
107
           grading,
108
           course_reg
109
         ) do
110
      {:ok, _} ->
111
        text(conn, "OK")
2✔
112

113
      {:error, {status, message}} ->
114
        conn
115
        |> put_status(status)
116
        |> text(message)
2✔
117
    end
118
  end
119

120
  def update(conn, _params) do
121
    conn
122
    |> put_status(:bad_request)
123
    |> text("Missing parameter")
2✔
124
  end
125

126
  def unsubmit(conn, %{"submissionid" => submission_id}) when is_ecto_id(submission_id) do
127
    course_reg = conn.assigns[:course_reg]
8✔
128

129
    case Assessments.unsubmit_submission(submission_id, course_reg) do
8✔
130
      {:ok, nil} ->
131
        text(conn, "OK")
4✔
132

133
      {:error, {status, message}} ->
134
        conn
135
        |> put_status(status)
136
        |> text(message)
4✔
137
    end
138
  end
139

140
  def unpublish_grades(conn, %{"submissionid" => submission_id}) when is_ecto_id(submission_id) do
141
    course_reg = conn.assigns[:course_reg]
3✔
142

143
    case Assessments.unpublish_grading(submission_id, course_reg) do
3✔
144
      {:ok, nil} ->
145
        text(conn, "OK")
2✔
146

147
      {:error, {status, message}} ->
148
        conn
149
        |> put_status(status)
150
        |> text(message)
1✔
151
    end
152
  end
153

154
  def publish_grades(conn, %{"submissionid" => submission_id}) when is_ecto_id(submission_id) do
155
    course_reg = conn.assigns[:course_reg]
4✔
156

157
    case Assessments.publish_grading(submission_id, course_reg) do
4✔
158
      {:ok, nil} ->
159
        text(conn, "OK")
2✔
160

161
      {:error, {status, message}} ->
162
        conn
163
        |> put_status(status)
164
        |> text(message)
2✔
165
    end
166
  end
167

168
  def publish_all_grades(conn, %{"assessmentid" => assessment_id})
169
      when is_ecto_id(assessment_id) do
170
    course_reg = conn.assigns[:course_reg]
1✔
171

172
    case Assessments.publish_all_graded(course_reg, assessment_id) do
1✔
173
      {:ok, nil} ->
174
        text(conn, "OK")
1✔
175

176
      {:error, {status, message}} ->
177
        conn
178
        |> put_status(status)
179
        |> text(message)
×
180
    end
181
  end
182

183
  def unpublish_all_grades(conn, %{"assessmentid" => assessment_id})
184
      when is_ecto_id(assessment_id) do
185
    course_reg = conn.assigns[:course_reg]
1✔
186

187
    case Assessments.unpublish_all(course_reg, assessment_id) do
1✔
188
      {:ok, nil} ->
189
        text(conn, "OK")
1✔
190

191
      {:error, {status, message}} ->
192
        conn
193
        |> put_status(status)
194
        |> text(message)
×
195
    end
196
  end
197

198
  def autograde_submission(conn, %{"submissionid" => submission_id}) do
199
    course_reg = conn.assigns[:course_reg]
2✔
200

201
    case Assessments.force_regrade_submission(submission_id, course_reg) do
2✔
202
      {:ok, nil} ->
203
        send_resp(conn, :no_content, "")
1✔
204

205
      {:error, {status, message}} ->
206
        conn
207
        |> put_status(status)
208
        |> text(message)
1✔
209
    end
210
  end
211

212
  def autograde_answer(conn, %{"submissionid" => submission_id, "questionid" => question_id}) do
213
    course_reg = conn.assigns[:course_reg]
2✔
214

215
    case Assessments.force_regrade_answer(submission_id, question_id, course_reg) do
2✔
216
      {:ok, nil} ->
217
        send_resp(conn, :no_content, "")
1✔
218

219
      {:error, {status, message}} ->
220
        conn
221
        |> put_status(status)
222
        |> text(message)
1✔
223
    end
224
  end
225

226
  def grading_summary(conn, %{"course_id" => course_id}) do
227
    case Assessments.get_group_grading_summary(course_id) do
1✔
228
      {:ok, cols, summary} ->
229
        render(conn, "grading_summary.json", cols: cols, summary: summary)
1✔
230
    end
231
  end
232

233
  swagger_path :index do
1✔
234
    get("/courses/{course_id}/admin/grading")
235

236
    summary("Get a list of all submissions with current user as the grader")
237

238
    security([%{JWT: []}])
239

240
    produces("application/json")
241

242
    parameters do
243
      group(
244
        :query,
245
        :boolean,
246
        "Show only students in the grader's group when true",
247
        required: false
248
      )
249
    end
250

251
    response(200, "OK", Schema.ref(:Submissions))
252
    response(401, "Unauthorised")
253
    response(403, "Forbidden")
254
  end
255

256
  swagger_path :unsubmit do
1✔
257
    post("/courses/{course_id}/admin/grading/{submissionId}/unsubmit")
258
    summary("Unsubmit submission. Can only be done by the Avenger of a student")
259
    security([%{JWT: []}])
260

261
    parameters do
262
      submissionId(:path, :integer, "submission id", required: true)
263
    end
264

265
    response(200, "OK")
266
    response(400, "Invalid parameters")
267
    response(403, "Forbidden")
268
    response(404, "Submission not found")
269
  end
270

271
  swagger_path :autograde_submission do
1✔
272
    post("/courses/{course_id}/admin/grading/{submissionId}/autograde")
273
    summary("Force re-autograding of an entire submission")
274
    security([%{JWT: []}])
275

276
    parameters do
277
      submissionId(:path, :integer, "submission id", required: true)
278
    end
279

280
    response(204, "Successful request")
281
    response(400, "Invalid parameters or submission not submitted")
282
    response(403, "Forbidden")
283
    response(404, "Submission not found")
284
  end
285

286
  swagger_path :autograde_answer do
1✔
287
    post("/courses/{course_id}/admin/grading/{submissionId}/{questionId}/autograde")
288
    summary("Force re-autograding of a question in a submission")
289
    security([%{JWT: []}])
290

291
    parameters do
292
      submissionId(:path, :integer, "submission id", required: true)
293
      questionId(:path, :integer, "question id", required: true)
294
    end
295

296
    response(204, "Successful request")
297
    response(400, "Invalid parameters or submission not submitted")
298
    response(403, "Forbidden")
299
    response(404, "Answer not found")
300
  end
301

302
  swagger_path :show do
1✔
303
    get("/courses/{course_id}/admin/grading/{submissionId}")
304

305
    summary("Get information about a specific submission to be graded")
306

307
    security([%{JWT: []}])
308

309
    produces("application/json")
310

311
    parameters do
312
      submissionId(:path, :integer, "submission id", required: true)
313
    end
314

315
    response(200, "OK", Schema.ref(:GradingInfo))
316
    response(400, "Invalid or missing parameter(s) or submission and/or question not found")
317
    response(401, "Unauthorised")
318
    response(403, "Forbidden")
319
  end
320

321
  swagger_path :update do
1✔
322
    post("/courses/{course_id}/admin/grading/{submissionId}/{questionId}")
323

324
    summary("Update marks given to the answer of a particular question in a submission")
325

326
    security([%{JWT: []}])
327

328
    consumes("application/json")
329
    produces("application/json")
330

331
    parameters do
332
      submissionId(:path, :integer, "submission id", required: true)
333
      questionId(:path, :integer, "question id", required: true)
334
      grading(:body, Schema.ref(:Grading), "adjustments for a question", required: true)
335
    end
336

337
    response(200, "OK")
338
    response(400, "Invalid or missing parameter(s) or submission and/or question not found")
339
    response(401, "Unauthorised")
340
    response(403, "Forbidden")
341
  end
342

343
  swagger_path :grading_summary do
1✔
344
    get("/courses/{course_id}/admin/grading/summary")
345

346
    summary("Receives a summary of grading items done by this grader")
347

348
    security([%{JWT: []}])
349

350
    produces("application/json")
351

352
    response(200, "OK", Schema.array(:GradingSummary))
353
    response(400, "Invalid or missing parameter(s) or submission and/or question not found")
354
    response(401, "Unauthorised")
355
    response(403, "Forbidden")
356
  end
357

358
  def swagger_definitions do
359
    %{
1✔
360
      Submissions:
361
        swagger_schema do
1✔
362
          type(:array)
363
          items(Schema.ref(:Submission))
364
        end,
365
      Submission:
366
        swagger_schema do
1✔
367
          properties do
1✔
368
            id(:integer, "Submission id", required: true)
369
            grade(:integer, "Grade given", required: true)
370
            xp(:integer, "XP earned", required: true)
371
            xpBonus(:integer, "Bonus XP for a given submission", required: true)
372
            xpAdjustment(:integer, "XP adjustment given", required: true)
373
            adjustment(:integer, "Grade adjustment given", required: true)
374

375
            status(
376
              Schema.ref(:AssessmentStatus),
377
              "One of 'not_attempted/attempting/attempted/submitted' indicating whether the assessment has been attempted by the current user",
378
              required: true
379
            )
380

381
            gradedCount(:integer, "Number of questions in this submission that have been graded",
382
              required: true
383
            )
384

385
            assessment(Schema.ref(:AssessmentInfo), "Assessment for which the submission is for",
386
              required: true
387
            )
388

389
            student(Schema.ref(:StudentInfo), "Student who created the submission",
390
              required: true
391
            )
392

393
            unsubmittedBy(Schema.ref(:GraderInfo))
394
            unsubmittedAt(:string, "Last unsubmitted at", format: "date-time", required: false)
395

396
            isGradingPublished(:boolean, "Whether the grading is published", required: true)
1✔
397
          end
398
        end,
399
      AssessmentInfo:
400
        swagger_schema do
1✔
401
          properties do
1✔
402
            id(:integer, "assessment id", required: true)
403

404
            config(Schema.ref(:AssessmentConfig), "Either mission/sidequest/path/contest",
405
              required: true
406
            )
407

408
            title(:string, "Mission title", required: true)
409

410
            maxGrade(
411
              :integer,
412
              "The max grade for this assessment",
413
              required: true
414
            )
415

416
            maxXp(
417
              :integer,
418
              "The max xp for this assessment",
419
              required: true
420
            )
421

422
            questionCount(:integer, "number of questions in this assessment", required: true)
1✔
423
          end
424
        end,
425
      StudentInfo:
426
        swagger_schema do
1✔
427
          properties do
1✔
428
            id(:integer, "student id", required: true)
429
            name(:string, "student name", required: true)
430
            username(:string, "student username", required: true)
431
            groupName(:string, "name of student's group")
432
            groupLeaderId(:integer, "user id of group leader")
1✔
433
          end
434
        end,
435
      GraderInfo:
436
        swagger_schema do
1✔
437
          properties do
1✔
438
            id(:integer, "grader id", required: true)
439
            name(:string, "grader name", required: true)
1✔
440
          end
441
        end,
442
      GradingInfo:
443
        swagger_schema do
1✔
444
          description(
445
            "A list of questions with submitted answers, solution and previous grading info " <>
446
              "if available"
447
          )
448

449
          type(:array)
450

451
          items(
452
            Schema.new do
1✔
453
              properties do
1✔
454
                question(Schema.ref(:Question), "Question", required: true)
455
                grade(Schema.ref(:Grade), "Grading information", required: true)
456
                student(Schema.ref(:StudentInfo), "Student", required: true)
457

458
                solution(
459
                  :string,
460
                  "the marking scheme and model solution to this question. Only available for programming questions",
461
                  required: true
462
                )
463

464
                maxGrade(
465
                  :integer,
466
                  "the max grade that can be given to this question",
467
                  required: true
468
                )
469

470
                maxXp(
1✔
471
                  :integer,
472
                  "the max xp that can be given to this question",
473
                  required: true
474
                )
475
              end
476
            end
477
          )
478
        end,
479
      GradingSummary:
480
        swagger_schema do
1✔
481
          description("Summary of grading items for current user as the grader")
482

483
          properties do
1✔
484
            groupName(:string, "Name of group this grader is in", required: true)
485
            leaderName(:string, "Name of group leader", required: true)
486
            submittedMissions(:integer, "Number of submitted missions", required: true)
487
            submittedSidequests(:integer, "Number of submitted sidequests", required: true)
488
            ungradedMissions(:integer, "Number of ungraded missions", required: true)
489
            ungradedSidequests(:integer, "Number of ungraded sidequests", required: true)
1✔
490
          end
491
        end,
492
      Grade:
493
        swagger_schema do
1✔
494
          properties do
1✔
495
            grade(:integer, "Grade awarded by autograder")
496
            xp(:integer, "XP awarded by autograder")
497
            adjustment(:integer, "Grade adjustment given")
498
            xpAdjustment(:integer, "XP adjustment given", required: true)
499
            grader(Schema.ref(:GraderInfo))
500
            gradedAt(:string, "Last graded at", format: "date-time", required: false)
501
            comments(:string, "Comments given by grader")
1✔
502
          end
503
        end,
504
      Grading:
505
        swagger_schema do
1✔
506
          properties do
1✔
507
            grading(
1✔
508
              Schema.new do
1✔
509
                properties do
1✔
510
                  adjustment(:integer, "Grade adjustment given")
511
                  xpAdjustment(:integer, "XP adjustment given")
512
                  comments(:string, "Comments given by grader")
1✔
513
                end
514
              end
515
            )
516
          end
517
        end
518
    }
519
  end
520
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc