Skip to content

Commit a6f6409

Browse files
feat(rubrics): cutover to v2 rubric implementation
- add copy-on-write behavior for v2 rubrics and child rows - add "active rubric" association to rubric based response questions - port over behavior of advancing changes between incompatible rubric versions, warn user when this happens - UI for applying rubric result to individual questions in playground view
1 parent 46ab859 commit a6f6409

78 files changed

Lines changed: 3813 additions & 940 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 48 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,60 @@
11
# frozen_string_literal: true
22
module Course::Assessment::Question::RubricBasedResponseControllerConcern
3-
include Course::Assessment::Question::RubricBasedResponseQuestionConcern
43
extend ActiveSupport::Concern
54

6-
def create_new_category_grade_instances(new_category_ids)
7-
answers = Course::Assessment::Answer.where(
8-
actable_type: 'Course::Assessment::Answer::RubricBasedResponse',
9-
question_id: @rubric_based_response_question.acting_as.id
10-
).includes(:actable).map(&:actable)
11-
12-
new_category_selections = answers.product(new_category_ids).map do |answer, category_id|
13-
{
14-
answer_id: answer.id,
15-
category_id: category_id,
16-
criterion_id: nil,
17-
grade: nil,
18-
explanation: nil
19-
}
20-
end
21-
22-
selections = Course::Assessment::Answer::RubricBasedResponseSelection.insert_all(new_category_selections)
23-
raise ActiveRecord::Rollback if !new_category_selections.empty? && (selections.nil? || selections.rows.empty?)
24-
25-
true
5+
def preload_criterions_per_category
6+
@rubric_based_response_question = Course::Assessment::Question::RubricBasedResponse.
7+
includes(categories: :criterions).
8+
find(@rubric_based_response_question.id)
269
end
2710

28-
def update_all_submission_answer_grades
29-
all_assessment_submission_ids = @assessment.submissions.map(&:id)
30-
@all_rubric_based_response_answers = Course::Assessment::Answer.where(
31-
submission_id: all_assessment_submission_ids,
32-
actable_type: 'Course::Assessment::Answer::RubricBasedResponse'
33-
).includes(:question, actable: [selections: :criterion])
34-
35-
answer_score_array = construct_answer_score_array
11+
# Keeps the question's v2 active_rubric in sync with its (just-saved) v1 rubric, copy-on-write: builds the
12+
# proposed content from the current non-bonus categories and either reuses the existing active rubric
13+
# (unchanged content) or persists a new version and repoints active_rubric_id at it.
14+
#
15+
# Returns:
16+
# :synced -- nothing to do, or the grading evaluations were advanced to the new version.
17+
# :advance_required -- the new version is structurally INCOMPATIBLE with the previous one, there are
18+
# graded answers, and confirm_advance is false. The advance (which may null some
19+
# categories) is NOT performed; the caller should roll the whole update back, have
20+
# the user confirm, and re-submit with confirm_advance: true.
21+
def sync_active_rubric(confirm_advance: false)
22+
question = @rubric_based_response_question
23+
categories = Course::Rubric.categories_from_v1(question)
24+
return :synced if categories.empty? # no gradable categories yet; leave active_rubric_id untouched
25+
26+
previous_active = question.active_rubric
27+
synced = build_synced_rubric(question, previous_active, categories)
28+
return :synced if question.active_rubric_id == synced.id # content unchanged; nothing to repoint/advance
29+
30+
# active_rubric_id lives on the polymorphic question; write through acting_as (update_column is not
31+
# proxied by acts_as).
32+
question.acting_as.update_column(:active_rubric_id, synced.id)
33+
34+
advance_service = Course::Rubric::GradingEvaluationAdvanceService.new(question, synced)
35+
if !confirm_advance && previous_active&.incompatible_with?(synced) && advance_service.pending?
36+
return :advance_required
37+
end
3638

37-
raise ActiveRecord::Rollback unless Course::Assessment::Answer.upsert_all(answer_score_array, update_only: :grade,
38-
unique_by: :id)
39+
advance_service.advance!
40+
:synced
41+
end
3942

40-
true
43+
# Caps existing answer grades at the question's maximum_grade. Needed when maximum_grade is lowered
44+
# without a rubric-content change (so the advance service, which also clamps, does not run).
45+
def clamp_answer_grades_to_maximum
46+
maximum_grade = @rubric_based_response_question.maximum_grade
47+
Course::Assessment::Answer.where(question_id: @rubric_based_response_question.acting_as.id).
48+
where('grade > ?', maximum_grade).
49+
update_all(grade: maximum_grade)
4150
end
4251

43-
def preload_criterions_per_category
44-
@rubric_based_response_question = Course::Assessment::Question::RubricBasedResponse.
45-
includes(categories: :criterions).
46-
find(@rubric_based_response_question.id)
52+
def build_synced_rubric(question, previous_active, categories)
53+
if previous_active
54+
previous_active.copy_with(grading_prompt: question.ai_grading_custom_prompt,
55+
model_answer: question.ai_grading_model_answer, categories: categories)
56+
else
57+
Course::Rubric.build_from_v1(question, current_course).tap(&:save!)
58+
end
4759
end
4860
end

app/controllers/concerns/course/assessment/question/rubric_based_response_question_concern.rb

Lines changed: 0 additions & 28 deletions
This file was deleted.

app/controllers/course/assessment/question/rubric_based_responses_controller.rb

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
# frozen_string_literal: true
2-
class Course::Assessment::Question::RubricBasedResponsesController < Course::Assessment::Question::Controller
3-
include Course::Assessment::Question::RubricBasedResponseQuestionConcern
2+
class Course::Assessment::Question::RubricBasedResponsesController < Course::Assessment::Question::Controller # rubocop:disable Metrics/ClassLength
43
include Course::Assessment::Question::RubricBasedResponseControllerConcern
54

65
build_and_authorize_new_question :rubric_based_response_question,
@@ -18,6 +17,7 @@ def create
1817
success = add_bonus_category_to_rubric_based_question
1918

2019
if success
20+
sync_active_rubric
2121
render json: { redirectUrl: course_assessment_path(current_course, @assessment) }
2222
else
2323
head :bad_request
@@ -41,9 +41,12 @@ def edit
4141
end
4242

4343
def update
44-
update_skill_ids_if_params_present(rubric_based_response_question_params[:question_assessment])
45-
46-
if update_rubric_based_response_question
44+
case update_rubric_based_response_question
45+
when :needs_confirmation
46+
# The rubric changed incompatibly and there are graded answers: nothing was saved. The frontend
47+
# confirms with the user and re-submits the same update with confirm_rubric_advance: true.
48+
render json: { error: 'rubric_advance_confirmation_required' }, status: :conflict
49+
when :synced
4750
render json: { redirectUrl: course_assessment_path(current_course, @assessment) }
4851
else
4952
render json: { errors: @rubric_based_response_question.errors.messages.values.flatten.to_sentence },
@@ -62,12 +65,6 @@ def destroy
6265
end
6366
end
6467

65-
def migrate_rubric
66-
v2_rubric = Course::Rubric.build_from_v1(@rubric_based_response_question, current_course)
67-
v2_rubric.save!
68-
render partial: 'course/rubrics/rubric', locals: { rubric: v2_rubric }, status: :created
69-
end
70-
7168
private
7269

7370
def add_bonus_category_to_rubric_based_question
@@ -89,17 +86,42 @@ def add_bonus_category_to_rubric_based_question
8986
end
9087
end
9188

89+
# Updates the v1 question and syncs the v2 active rubric. Returns :synced on success, :failed on a
90+
# validation error, or :needs_confirmation when an incompatible rubric change with graded answers needs
91+
# the user's confirmation -- in which case the entire transaction is rolled back (nothing is saved) so the
92+
# user can confirm on the still-open edit page and re-submit with confirm_rubric_advance: true.
9293
def update_rubric_based_response_question
93-
ActiveRecord::Base.transaction do
94-
existing_category_ids = @rubric_based_response_question.categories.pluck(:id)
95-
raise ActiveRecord::Rollback unless @rubric_based_response_question.update(
96-
rubric_based_response_question_params.except(:question_assessment)
97-
)
98-
99-
new_category_ids = @rubric_based_response_question.reload.categories.pluck(:id) - existing_category_ids
100-
create_new_category_grade_instances(new_category_ids) if new_category_ids.present?
101-
update_all_submission_answer_grades
94+
needs_confirmation = false
95+
saved = ActiveRecord::Base.transaction do
96+
raise ActiveRecord::Rollback unless apply_question_update
97+
98+
if sync_active_rubric(confirm_advance: confirm_rubric_advance?) == :advance_required
99+
needs_confirmation = true
100+
raise ActiveRecord::Rollback
101+
end
102+
true
102103
end
104+
105+
return :needs_confirmation if needs_confirmation
106+
107+
saved ? :synced : :failed
108+
end
109+
110+
# Updates the v1 question (skills + attributes) and re-clamps grades when the maximum changed. Returns
111+
# whether the question itself saved.
112+
def apply_question_update # rubocop:disable Naming/PredicateMethod
113+
update_skill_ids_if_params_present(rubric_based_response_question_params[:question_assessment])
114+
previous_maximum_grade = @rubric_based_response_question.maximum_grade
115+
return false unless @rubric_based_response_question.update(
116+
rubric_based_response_question_params.except(:question_assessment)
117+
)
118+
119+
clamp_answer_grades_to_maximum if @rubric_based_response_question.maximum_grade != previous_maximum_grade
120+
true
121+
end
122+
123+
def confirm_rubric_advance?
124+
ActiveRecord::Type::Boolean.new.cast(params[:confirm_rubric_advance])
103125
end
104126

105127
def rubric_based_response_question_params

app/controllers/course/assessment/rubrics_controller.rb

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,13 @@ def rubric_answers
3838
end
3939

4040
def fetch_answer_evaluations
41-
@answer_evaluations = @rubric.answer_evaluations.includes(answer: { submission: :creator })
41+
# The selected version's (non-dismissed) playground evaluations -- shown in the playground table -- plus
42+
# every answer's official grading evaluation (shown in the Apply table). Dismissed (playground_hidden)
43+
# evaluations are excluded. The frontend splits the two by evaluationType.
44+
@answer_evaluations = @rubric.answer_evaluations.playground.includes(answer: { submission: :creator })
45+
@grading_evaluations = Course::Rubric::AnswerEvaluation.grading.
46+
where(answer_id: @question.answers.select(:id)).
47+
includes(:selections, answer: { submission: :creator })
4248
end
4349

4450
def fetch_mock_answer_evaluations
@@ -96,16 +102,14 @@ def evaluate_mock_answer
96102
render partial: 'course/rubrics/mock_answer_evaluation', locals: { answer_evaluation: @mock_answer_evaluation }
97103
end
98104

99-
def evaluate_answer # rubocop:disable Metrics/AbcSize
105+
def evaluate_answer
100106
answer = @question.answers.find(params.permit(:answer_id)[:answer_id])
101107
head :bad_request unless answer&.specific.is_a?(Course::Assessment::Answer::RubricBasedResponse)
102108

103-
@answer_evaluation =
104-
@rubric.answer_evaluations.find_by(answer: answer) ||
105-
Course::Rubric::AnswerEvaluation.create({
106-
rubric: @rubric,
107-
answer: answer
108-
})
109+
# Reuse (and un-hide) the existing playground evaluation for this (answer, rubric) if one exists, else
110+
# start a fresh one. Re-evaluating a dismissed answer brings it back into the table.
111+
@answer_evaluation = Course::Rubric::AnswerEvaluation.find_or_build_playground(answer: answer, rubric: @rubric)
112+
@answer_evaluation.save!
109113

110114
question_adapter = Course::Assessment::Question::QuestionAdapter.new(answer.question)
111115
rubric_adapter = Course::Rubric::RubricAdapter.new(@rubric)
@@ -117,9 +121,11 @@ def evaluate_answer # rubocop:disable Metrics/AbcSize
117121
render partial: 'course/rubrics/answer_evaluation', locals: { answer_evaluation: @answer_evaluation }
118122
end
119123

124+
# "Dismiss" no longer deletes the evaluation -- it hides it from the playground table (keeping the data),
125+
# so a later re-evaluation can bring it back.
120126
def delete_answer_evaluations
121-
answer_evaluation = @rubric.answer_evaluations.find_by(answer_id: params.permit(:answer_id)[:answer_id])
122-
answer_evaluation&.destroy!
127+
answer_evaluation = @rubric.answer_evaluations.playground.find_by(answer_id: params.permit(:answer_id)[:answer_id])
128+
answer_evaluation&.update!(evaluation_type: :playground_hidden)
123129
end
124130

125131
def delete_mock_answer_evaluations
@@ -132,13 +138,29 @@ def delete_mock_answer_evaluations
132138
mock_answer.destroy! if mock_answer.rubric_evaluations.empty?
133139
end
134140

135-
def export_evaluations
136-
job = Course::Rubric::RubricEvaluationExportJob.perform_later(
137-
current_course, @rubric.id, @question.id
141+
def apply_evaluations
142+
@should_apply_unevaluated = params.permit(:should_apply_unevaluated)[:should_apply_unevaluated]
143+
if should_raise_unevaluated_warning?
144+
render json: { error: 'One or more answers are unevaluated' }, status: :bad_request and return
145+
end
146+
147+
job = Course::Rubric::ApplyEvaluationsJob.perform_later(
148+
current_course, @rubric.id, params.require(:answer_ids)
138149
).job
139150
render partial: 'jobs/submitted', locals: { job: job }
140151
end
141152

153+
# Points the question's active_rubric at this rubric and carries the graded answers' grading evaluations
154+
# forward to it. The frontend warns the user first when this rubric is structurally incompatible with the
155+
# current active one (which may null some graded categories); by the time this is called the user has
156+
# accepted that.
157+
def set_active
158+
# active_rubric_id lives on the polymorphic question; the advance service takes the RBR actable.
159+
@question.update_column(:active_rubric_id, @rubric.id)
160+
Course::Rubric::GradingEvaluationAdvanceService.new(@question.specific, @rubric).advance!
161+
head :ok
162+
end
163+
142164
private
143165

144166
def create_params
@@ -154,4 +176,11 @@ def create_params
154176
def initialize_mock_answer_evaluations_params
155177
params.require(:mock_answer_ids)
156178
end
179+
180+
def should_raise_unevaluated_warning?
181+
!ActiveRecord::Type::Boolean.new.cast(@should_apply_unevaluated) &&
182+
Course::Assessment::Answer.where(id: answer_ids).includes(:answer_evaluations).any? do |answer|
183+
answer.answer_evaluations.playground.find_by(rubric: @rubric).nil?
184+
end
185+
end
157186
end

app/controllers/course/assessment/submission/submissions_controller.rb

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,11 @@ def edit
6565

6666
@monitoring_session_id = monitoring_service&.session&.id if should_monitor?
6767
@submission = @submission.calculated(:graded_at, :grade) unless @submission.attempting?
68-
@answers = @submission.answers.includes(actable: [grades: [question_grade: :category]])
68+
# Preload each answer's question and its active_rubric (+ categories) so ensure_rubric_grading_evaluations
69+
# and the rubric panel don't N+1 over the parent question now that active_rubric lives there.
70+
@answers = @submission.answers.includes(actable: [grades: [question_grade: :category]],
71+
question: { active_rubric: :categories })
72+
ensure_rubric_grading_evaluations
6973
end
7074

7175
def auto_grade
@@ -323,6 +327,18 @@ def delete_all
323327

324328
private
325329

330+
# When a grader opens a (submitted) submission, make sure every rubric-based answer has a v2 grading
331+
# evaluation so the rubric panel is editable and the breakdown persists, even for answers never
332+
# auto-graded. Idempotent and grader-only; ungraded answers in an attempting submission are skipped.
333+
def ensure_rubric_grading_evaluations
334+
return if @submission.attempting? || cannot?(:grade, @submission)
335+
336+
@answers.each do |answer|
337+
actable = answer.actable
338+
actable.ensure_grading_evaluation! if actable.is_a?(Course::Assessment::Answer::RubricBasedResponse)
339+
end
340+
end
341+
326342
def create_params
327343
{ course_user: current_course_user }
328344
end
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# frozen_string_literal: true
2+
# Applies a rubric version's evaluations to the official grades of the given answers. For each answer it
3+
# reuses the existing +llm+ evaluation for (answer, rubric) when present (no re-run), otherwise evaluates
4+
# once; then mirrors that evaluation into the answer's +grading+ evaluation, sets the grade, and drafts an
5+
# AI feedback post. Replaces the old v1 "export to RubricBasedResponse" job.
6+
class Course::Rubric::ApplyEvaluationsJob < ApplicationJob
7+
include TrackableJob
8+
9+
queue_as :highest
10+
11+
def perform_tracked(course, rubric_id, answer_ids)
12+
rubric = course.rubrics.find(rubric_id)
13+
Course::Assessment::Answer.where(id: answer_ids).includes(:actable).find_each do |answer|
14+
apply(rubric, answer)
15+
end
16+
end
17+
18+
private
19+
20+
def apply(rubric, answer)
21+
evaluation = rubric.answer_evaluations.playground.find_by(answer: answer) || evaluate(rubric, answer)
22+
23+
grading = Course::Rubric::GradingEvaluationMirrorService.mirror(answer, evaluation)
24+
grade = Course::Rubric::GradingEvaluationMirrorService.total_grade(grading, answer.question.maximum_grade)
25+
answer.update_column(:grade, grade)
26+
Course::Assessment::Answer::AiGeneratedPostService.new(answer, grading.feedback).create_ai_generated_draft_post
27+
end
28+
29+
# Runs the LLM once for an answer with no (visible) playground evaluation for this rubric, populating a
30+
# fresh one (the playground adapter writes selections only -- it does not touch the grade/grading eval).
31+
def evaluate(rubric, answer)
32+
evaluation = Course::Rubric::AnswerEvaluation.find_or_build_playground(answer: answer, rubric: rubric)
33+
# Applying populates the playground evaluation but keeps a brand-new one hidden from the playground table.
34+
evaluation.evaluation_type = :playground_hidden if evaluation.new_record?
35+
evaluation.save!
36+
question_adapter = Course::Assessment::Question::QuestionAdapter.new(answer.question)
37+
rubric_adapter = Course::Rubric::RubricAdapter.new(rubric)
38+
answer_adapter = Course::Assessment::Answer::RubricPlaygroundAnswerAdapter.new(answer, evaluation)
39+
40+
llm_response = Course::Rubric::LlmService.new(question_adapter, rubric_adapter, answer_adapter).evaluate
41+
answer_adapter.save_llm_results(llm_response)
42+
evaluation.reload
43+
end
44+
end

0 commit comments

Comments
 (0)