Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ def category_params
# :allow_randomization,
:allow_mrq_options_randomization,
:programming_max_time_limit,
:rubric_grading_prompt_enabled,
:rubric_grading_prompt,
assessment_categories_attributes: [
:id,
:title,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,6 @@ def bulk_save_new_answer_actables(new_answers_group_by_actables)
ActiveRecord::Base.transaction do
new_answers_group_by_actables.each_key do |key|
key.constantize.import! new_answers_group_by_actables[key], recursive: true
if key.constantize == Course::Assessment::Answer::RubricBasedResponse
new_answers_group_by_actables[key].each(&:create_category_grade_instances)
end
end
end
end
Expand Down
21 changes: 21 additions & 0 deletions app/models/course.rb
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,27 @@ def programming_max_time_limit=(time)
settings(:course_assessments_component).programming_max_time_limit = time
end

# Whether the course-wide grading prompt is applied during rubric AI grading. Independent of the prompt
# text so toggling off preserves the text for later re-enabling.
def rubric_grading_prompt_enabled
settings(:course_assessments_component).rubric_grading_prompt_enabled || false
end

def rubric_grading_prompt_enabled=(enabled)
settings(:course_assessments_component).rubric_grading_prompt_enabled =
ActiveRecord::Type::Boolean.new.cast(enabled)
end

# Course-wide prompt prepended before each question's own grading prompt during rubric AI grading, applied
# only when rubric_grading_prompt_enabled (see Course::Rubric::RubricAdapter#grading_prompt).
def rubric_grading_prompt
settings(:course_assessments_component).rubric_grading_prompt
end

def rubric_grading_prompt=(prompt)
settings(:course_assessments_component).rubric_grading_prompt = prompt.presence
end

def codaveri_feedback_workflow
settings(:course_codaveri_component).feedback_workflow
end
Expand Down
21 changes: 2 additions & 19 deletions app/models/course/assessment/answer/rubric_based_response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ class Course::Assessment::Answer::RubricBasedResponse < ApplicationRecord
# record, so they are stashed during #assign_params and persisted once the answer itself saves.
after_save :persist_grading_selections, if: -> { @pending_grading_selections.present? }

# Deprecated link to the v1 response selection model. Remains because v1 tables will not be removed.
# Replaced by an indirect link via Answer -> Rubric::AnswerEvaluation -> Rubric::AnswerEvaluation::Selection
has_many :selections, class_name: 'Course::Assessment::Answer::RubricBasedResponseSelection',
dependent: :destroy, foreign_key: :answer_id, inverse_of: :answer

accepts_nested_attributes_for :selections, allow_destroy: true

# Specific implementation of Course::Assessment::Answer#reset_answer
def reset_answer
self.answer_text = question.actable.template_text || ''
Expand Down Expand Up @@ -86,23 +86,6 @@ def ensure_grading_evaluation!
end
end

def create_category_grade_instances
answer.class.transaction do
new_category_selections = question.specific.categories.map do |category|
{
answer_id: id,
category_id: category.id,
criterion_id: nil,
grade: nil,
explanation: nil
}
end

selections = Course::Assessment::Answer::RubricBasedResponseSelection.insert_all(new_category_selections)
raise ActiveRecord::Rollback if !new_category_selections.empty? && (selections.nil? || selections.rows.empty?)
end
end

private

def set_default
Expand Down
6 changes: 4 additions & 2 deletions app/models/course/assessment/question.rb
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,10 @@ def copy_attributes(other)
end

# Duplicates this question's grading contexts (see Course::Assessment::Question::GradingContext). Called from
# each rubric-gradable actable's #initialize_duplicate with +other+ = the source actable being duplicated.
# * As a CONSUMER: deep-copy the contexts this question pulls from onto the duplicate.
# the #initialize_duplicate of every type that can consume contexts (RBR, forum) OR be a sibling-answer
# source (text response), with +other+ = the source actable being duplicated.
# * As a CONSUMER: deep-copy the contexts this question pulls from onto the duplicate (no-op for source-only
# types, which have none).
# * As a SOURCE: re-point any already-duplicated contexts that reference this question, so a duplicate
# consumer pulls from the duplicate source. Together with GradingContext#initialize_duplicate, the source
# linkage is preserved regardless of which of the two questions is duplicated first.
Expand Down
6 changes: 6 additions & 0 deletions app/models/course/assessment/question/forum_post_response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ def attempt(submission, last_attempt = nil)

def initialize_duplicate(duplicator, other)
copy_attributes(other)

# active_rubric lives on the polymorphic question; the dup'd acting_as carries the source's
# active_rubric_id over, so replace it with a duplicate of the source rubric (re-homed to the destination
# course by Course::Rubric#initialize_duplicate) instead of sharing the source's. Only rubric-mode forum
# questions have one; a default-mode question leaves it nil.
self.active_rubric = duplicator.duplicate(other.active_rubric) if other.active_rubric
initialize_grading_context_duplicates(duplicator, other)
end

Expand Down
4 changes: 4 additions & 0 deletions app/models/course/assessment/question/text_response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ def initialize_duplicate(duplicator, other)
else
self.solutions = duplicator.duplicate(other.solutions)
end

# A text response is never a context CONSUMER, but it can be a sibling-answer SOURCE, so it still needs
# the source fix-up pass when duplicated (see Course::Assessment::Question#initialize_grading_context_duplicates).
initialize_grading_context_duplicates(duplicator, other)
end

def build_at_least_one_group_one_point
Expand Down
14 changes: 13 additions & 1 deletion app/models/course/rubric/rubric_adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ def formatted_rubric_categories
end.join("\n\n")
end

# The course-wide grading prompt (set in the assessment settings page) is prepended before the question's
# own grading prompt when enabled and present, so shared grading guidance applies to every rubric-graded
# question in the course. Either part may be blank; the separator only appears when both are present.
def grading_prompt
@rubric.grading_prompt
[course_grading_prompt, @rubric.grading_prompt.presence].compact.join("\n\n")
end

def model_answer
Expand Down Expand Up @@ -62,4 +65,13 @@ def build_category_schema(category, field_name)
'description' => "Selected criterion and explanation for #{field_name} #{category.name}"
}
end

private

def course_grading_prompt
course = @rubric.course
return nil unless course.rubric_grading_prompt_enabled

course.rubric_grading_prompt.presence
end
end
2 changes: 2 additions & 0 deletions app/views/course/admin/assessment_settings/edit.json.jbuilder
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ json.showStdoutAndStderr current_course.show_stdout_and_stderr || false
json.allowRandomization current_course.allow_randomization || false
json.allowMrqOptionsRandomization current_course.allow_mrq_options_randomization || false
json.maxProgrammingTimeLimit current_course.programming_max_time_limit if can?(:manage, :all)
json.rubricGradingPromptEnabled current_course.rubric_grading_prompt_enabled
json.rubricGradingPrompt current_course.rubric_grading_prompt || ''

json.canCreateCategories can?(:create, Course::Assessment::Category.new(course: current_course))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ const AssessmentsSettingsForm = forwardRef<
.nullable()
.typeError(t(translations.maxTimeLimitRequired))
.min(1, t(translations.positiveMaxTimeLimitRequired)),
rubricGradingPrompt: yup.string().when('rubricGradingPromptEnabled', {
is: true,
then: yup
.string()
.trim()
.required(t(translations.rubricGradingPromptRequired)),
}),
});

return (
Expand All @@ -42,7 +49,7 @@ const AssessmentsSettingsForm = forwardRef<
onSubmit={props.onSubmit}
validates={validationSchema}
>
{(control): JSX.Element => (
{(control, watch): JSX.Element => (
<>
<Section sticksToNavbar title={t(translations.assessmentSettings)}>
{/* Randomized Assessment is temporarily hidden (PR#5406) */}
Expand Down Expand Up @@ -137,6 +144,49 @@ const AssessmentsSettingsForm = forwardRef<
)}
</Section>

<Section sticksToNavbar title={t(translations.rubricGrading)}>
<Controller
control={control}
name="rubricGradingPromptEnabled"
render={({ field, fieldState }): JSX.Element => (
<FormCheckboxField
disabled={props.disabled}
field={field}
fieldState={fieldState}
label={t(translations.useRubricGradingPrompt)}
/>
)}
/>

<Typography
className={
watch('rubricGradingPromptEnabled') ? '' : 'opacity-50'
}
color="text.secondary"
variant="body2"
>
{t(translations.rubricGradingPromptHint)}
</Typography>

<Controller
control={control}
name="rubricGradingPrompt"
render={({ field, fieldState }): JSX.Element => (
<FormTextField
disabled={
props.disabled || !watch('rubricGradingPromptEnabled')
}
field={field}
fieldState={fieldState}
fullWidth
multiline
rows={6}
variant="outlined"
/>
Comment thread
adi-herwana-nus marked this conversation as resolved.
)}
/>
</Section>

<Section
sticksToNavbar
subtitle={t(translations.categoriesAndTabsSubtitle)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export const updateAssessmentSettings = async (
allow_randomization: data.allowRandomization,
allow_mrq_options_randomization: data.allowMrqOptionsRandomization,
programming_max_time_limit: data.maxProgrammingTimeLimit,
rubric_grading_prompt_enabled: data.rubricGradingPromptEnabled,
rubric_grading_prompt: data.rubricGradingPrompt,
assessment_categories_attributes: data.categories.map((category) => ({
id: category.id,
title: category.title,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,24 @@ export default defineMessages({
id: 'course.admin.AssessmentSettings.enableMcqChoicesRandomisations',
defaultMessage: 'Randomise MCQ choices',
},
rubricGrading: {
id: 'course.admin.AssessmentSettings.rubricGrading',
defaultMessage: 'AI Rubric Grading',
},
useRubricGradingPrompt: {
id: 'course.admin.AssessmentSettings.useRubricGradingPrompt',
defaultMessage: 'Use course-wide grading prompt',
},
rubricGradingPromptHint: {
id: 'course.admin.AssessmentSettings.rubricGradingPromptHint',
defaultMessage:
"When a question with a grading rubric is autograded with AI, these instructions will be inserted before the question's specific grading prompt.",
},
rubricGradingPromptRequired: {
id: 'course.admin.AssessmentSettings.rubricGradingPromptRequired',
defaultMessage:
'Please enter a grading prompt, or disable the course-wide grading prompt.',
},
deleteCategoryPromptAction: {
id: 'course.admin.AssessmentSettings.deleteCategoryPromptAction',
defaultMessage: 'Delete {title} category',
Expand Down
4 changes: 4 additions & 0 deletions client/app/types/course/admin/assessments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export interface AssessmentSettingsData {
categories: AssessmentCategory[];
canCreateCategories: boolean;
maxProgrammingTimeLimit?: number;
rubricGradingPromptEnabled: boolean;
rubricGradingPrompt?: string;
}

export interface AssessmentCategory {
Expand Down Expand Up @@ -62,6 +64,8 @@ export interface AssessmentSettingsPostData {
allow_randomization?: AssessmentSettingsData['allowRandomization'];
allow_mrq_options_randomization?: AssessmentSettingsData['allowMrqOptionsRandomization'];
programming_max_time_limit: AssessmentSettingsData['maxProgrammingTimeLimit'];
rubric_grading_prompt_enabled?: AssessmentSettingsData['rubricGradingPromptEnabled'];
rubric_grading_prompt?: AssessmentSettingsData['rubricGradingPrompt'];
assessment_categories_attributes?: {
id: AssessmentCategory['id'];
title: AssessmentCategory['title'];
Expand Down
12 changes: 12 additions & 0 deletions client/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,15 @@
"course.admin.AssessmentSettings.programmingQuestionSettings": {
"defaultMessage": "Programming Question settings"
},
"course.admin.AssessmentSettings.rubricGrading": {
"defaultMessage": "AI Rubric Grading"
},
"course.admin.AssessmentSettings.rubricGradingPromptHint": {
"defaultMessage": "When a question with a grading rubric is autograded with AI, these instructions will be inserted before the question's specific grading prompt."
},
"course.admin.AssessmentSettings.rubricGradingPromptRequired": {
"defaultMessage": "Please enter a grading prompt, or disable the course-wide grading prompt."
},
"course.admin.AssessmentSettings.seconds": {
"defaultMessage": "s"
},
Expand All @@ -515,6 +524,9 @@
"course.admin.AssessmentSettings.toTab": {
"defaultMessage": "to {tab}"
},
"course.admin.AssessmentSettings.useRubricGradingPrompt": {
"defaultMessage": "Use course-wide grading prompt"
},
"course.admin.CodaveriSettings.codaveriModel": {
"defaultMessage": "Model"
},
Expand Down
12 changes: 12 additions & 0 deletions client/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,15 @@
"course.admin.AssessmentSettings.programmingQuestionSettings": {
"defaultMessage": "프로그래밍 질문 설정"
},
"course.admin.AssessmentSettings.rubricGrading": {
"defaultMessage": "AI 루브릭 채점"
},
"course.admin.AssessmentSettings.rubricGradingPromptHint": {
"defaultMessage": "채점 루브릭이 있는 문제를 AI로 자동 채점할 때, 이 지침이 해당 문제의 개별 채점 프롬프트 앞에 삽입됩니다."
},
"course.admin.AssessmentSettings.rubricGradingPromptRequired": {
"defaultMessage": "채점 프롬프트를 입력하거나 코스 전체 채점 프롬프트를 비활성화하세요."
},
"course.admin.AssessmentSettings.seconds": {
"defaultMessage": "초"
},
Expand All @@ -515,6 +524,9 @@
"course.admin.AssessmentSettings.toTab": {
"defaultMessage": "{tab}로"
},
"course.admin.AssessmentSettings.useRubricGradingPrompt": {
"defaultMessage": "코스 전체 채점 프롬프트 사용"
},
"course.admin.CodaveriSettings.Some": {
"defaultMessage": "일부"
},
Expand Down
12 changes: 12 additions & 0 deletions client/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,15 @@
"course.admin.AssessmentSettings.programmingQuestionSettings": {
"defaultMessage": "编程题设置"
},
"course.admin.AssessmentSettings.rubricGrading": {
"defaultMessage": "AI 评分标准评分"
},
"course.admin.AssessmentSettings.rubricGradingPromptHint": {
"defaultMessage": "当带有评分标准的题目由 AI 自动评分时,这些说明将被插入到该题目专属评分提示之前。"
},
"course.admin.AssessmentSettings.rubricGradingPromptRequired": {
"defaultMessage": "请输入评分提示,或停用课程级评分提示。"
},
"course.admin.AssessmentSettings.seconds": {
"defaultMessage": "秒"
},
Expand All @@ -515,6 +524,9 @@
"course.admin.AssessmentSettings.toTab": {
"defaultMessage": "到 {tab}"
},
"course.admin.AssessmentSettings.useRubricGradingPrompt": {
"defaultMessage": "使用课程级评分提示"
},
"course.admin.CodaveriSettings.codaveriModel": {
"defaultMessage": "模型"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@
end
end

describe '#update persisting the course-wide rubric grading prompt' do
subject do
patch :update, as: :json, params: {
course_id: course,
course: { rubric_grading_prompt_enabled: true, rubric_grading_prompt: 'Grade generously' }
}
end

it 'stores the prompt and enable flag on the course settings' do
expect(subject).to render_template(:edit)
expect(course.reload.rubric_grading_prompt).to eq('Grade generously')
expect(course.reload.rubric_grading_prompt_enabled).to be(true)
end
end

describe 'moving actions' do
let!(:category1) { create(:course_assessment_category, course: course) }
let!(:category2) { create(:course_assessment_category, course: course) }
Expand Down
Loading