diff --git a/app/controllers/course/discussion/posts_controller.rb b/app/controllers/course/discussion/posts_controller.rb index dcfdb8788f0..e7ab63c85d9 100644 --- a/app/controllers/course/discussion/posts_controller.rb +++ b/app/controllers/course/discussion/posts_controller.rb @@ -33,9 +33,9 @@ def create def update if @post.update(post_params) respond_to do |format| - # Change post creator from system to updater if it is a codaveri feedback + # Change post creator from system to updater if it is a codaveri feedback or generated by AI # and send notification - if @post.published? && @post.codaveri_feedback && @post.creator_id == 0 + if @post.published? && (@post.codaveri_feedback || @post.is_ai_generated) && @post.creator_id == 0 @post.update(creator_id: current_user.id) update_topic_pending_status send_created_notification(@post) diff --git a/app/models/course/assessment/answer/rubric_based_response.rb b/app/models/course/assessment/answer/rubric_based_response.rb index aff634668e2..5354ec5a74a 100644 --- a/app/models/course/assessment/answer/rubric_based_response.rb +++ b/app/models/course/assessment/answer/rubric_based_response.rb @@ -36,6 +36,11 @@ def assign_grade_params(params) end end + # Rubric based responses should be graded in a job. + def grade_inline? + false + end + def csv_download ActionController::Base.helpers.strip_tags(answer_text) end diff --git a/app/models/course/assessment/question/rubric_based_response.rb b/app/models/course/assessment/question/rubric_based_response.rb index 4e270927e2a..e705825b45d 100644 --- a/app/models/course/assessment/question/rubric_based_response.rb +++ b/app/models/course/assessment/question/rubric_based_response.rb @@ -23,6 +23,10 @@ def auto_gradable? !categories.empty? end + def auto_grader + Course::Assessment::Answer::RubricAutoGradingService.new + end + def question_type 'RubricBasedResponse' end diff --git a/app/services/course/assessment/answer/prompts/rubric_auto_grading_output_format.json b/app/services/course/assessment/answer/prompts/rubric_auto_grading_output_format.json new file mode 100644 index 00000000000..528c5b248f1 --- /dev/null +++ b/app/services/course/assessment/answer/prompts/rubric_auto_grading_output_format.json @@ -0,0 +1,35 @@ +{ + "_type": "json_schema", + "type": "object", + "properties": { + "category_grades": { + "type": "array", + "items": { + "type": "object", + "properties": { + "category_id": { + "type": "number", + "description": "The ID of the rubric category" + }, + "criterion_id": { + "type": "number", + "description": "The ID of the criterion within the rubric category, must be one of the listed criteria for the rubric category" + }, + "explanation": { + "type": "string", + "description": "An explanation for why the criterion was selected" + } + }, + "required": ["category_id", "criterion_id", "explanation"], + "additionalProperties": false + }, + "description": "A list of criterions selected for each rubric category with explanations" + }, + "overall_feedback": { + "type": "string", + "description": "General feedback about the student's response, provided in HTML format and focused on how the student can improve according to the rubric" + } + }, + "required": ["category_grades", "overall_feedback"], + "additionalProperties": false +} diff --git a/app/services/course/assessment/answer/prompts/rubric_auto_grading_system_prompt.json b/app/services/course/assessment/answer/prompts/rubric_auto_grading_system_prompt.json new file mode 100644 index 00000000000..726da0f693a --- /dev/null +++ b/app/services/course/assessment/answer/prompts/rubric_auto_grading_system_prompt.json @@ -0,0 +1,5 @@ +{ + "_type": "prompt", + "input_variables": ["format_instructions"], + "template": "You are an expert grading assistant for educational assessments.\nYour task is to grade a student's response to a rubric-based question.\nYou will be provided with:\n1. The question prompt\n2. The rubric categories and criteria\n3. The student's response\n\nYou must analyze how well the student's response meets each rubric category's criteria\nand provide feedback accordingly.\n\nSpecial Note on Moderation:\nIf a rubric category is labeled as \"moderation\" and does not contain any grading criteria, do not attempt to assign a criterion. Instead, return `criterion_id: 0` and provide a neutral explanation such as \"No moderation criteria were assessed.\" This category is reserved for manual adjustment by educators.\n\nThe `overall_feedback` field **must be written in HTML** to support rich text rendering, and it **must emphasize how the student can improve their response** according to the rubric criteria.\n\n{format_instructions}" +} diff --git a/app/services/course/assessment/answer/prompts/rubric_auto_grading_user_prompt.json b/app/services/course/assessment/answer/prompts/rubric_auto_grading_user_prompt.json new file mode 100644 index 00000000000..98c7a1d2806 --- /dev/null +++ b/app/services/course/assessment/answer/prompts/rubric_auto_grading_user_prompt.json @@ -0,0 +1,5 @@ +{ + "_type": "prompt", + "input_variables": ["question_title", "question_description", "rubric_categories", "answer_text"], + "template": "QUESTION:\n{question_title}\n{question_description}\n\nRUBRIC CATEGORIES:\n{rubric_categories}\n\nSTUDENT RESPONSE:\n{answer_text}" +} diff --git a/app/services/course/assessment/answer/rubric_auto_grading_service.rb b/app/services/course/assessment/answer/rubric_auto_grading_service.rb new file mode 100644 index 00000000000..bdddd6f996b --- /dev/null +++ b/app/services/course/assessment/answer/rubric_auto_grading_service.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true +class Course::Assessment::Answer::RubricAutoGradingService < + Course::Assessment::Answer::AutoGradingService + def evaluate(answer) + answer.correct, grade, messages, feedback = evaluate_answer(answer.actable) + answer.auto_grading.result = { messages: messages } + create_ai_generated_draft_post(answer, feedback) + grade + end + + private + + # Grades the given answer. + # + # @param [Course::Assessment::Answer::RubricBasedResponse] answer The answer specified by the + # @return [Array<(Boolean, Integer, Object, String)>] The correct status, grade, messages to be + # assigned to the grading, and feedback for the draft post. + def evaluate_answer(answer) + question = answer.question.actable + llm_service = Course::Assessment::Answer::RubricLlmService.new + llm_response = llm_service.evaluate(question, answer) + process_llm_grading_response(question, answer, llm_response) + end + + # Processes the LLM response into grades and feedback, and updates the answer. + # @param [Course::Assessment::Question] question The question to be graded. + # @param [Course::Assessment::Answer::RubricBasedResponse] answer The answer to update. + # @param [Hash] llm_response The parsed LLM response containing grading information + # @return [Array<(Boolean, Integer, Object, String)>] The correct status, grade, and feedback messages. + def process_llm_grading_response(question, answer, llm_response) + category_grades = process_category_grades(question, llm_response) + + # For rubric-based questions, update the answer's selections and grade to database + update_answer_selections(answer, category_grades) + grade = update_answer_grade(answer, category_grades) + + # Currently no support for correctness in rubric-based questions + [true, grade, ['success'], llm_response['overall_feedback']] + end + + # Processes category grades from LLM response into a structured format + # @param [Course::Assessment::Question] question The question to be graded. + # @param [Hash] llm_response The parsed LLM response with category grades + # @return [Array] Array of processed category grades. + def process_category_grades(question, llm_response) + category_lookup = question.categories.index_by(&:id) + llm_response['category_grades'].filter_map do |category_grade| + category = category_lookup[category_grade['category_id']] + next unless category + + # Skip 'moderation' category as it does not have criterions + criterion = category.criterions.find { |c| c.id == category_grade['criterion_id'] } + next unless criterion + + { + category_id: category_grade['category_id'], + criterion_id: criterion&.id, + grade: criterion&.grade, + explanation: category_grade['explanation'] + } + end + end + + # Updates the answer's selections and total grade based on the graded categories. + # + # @param [Course::Assessment::Answer::RubricBasedResponse] answer The answer to update. + # @param [Array] category_grades The processed category grades. + # @return [void] + def update_answer_selections(answer, category_grades) + if answer.selections.empty? + answer.create_category_grade_instances + answer.reload + end + selection_lookup = answer.selections.index_by(&:category_id) + params = { + selections_attributes: category_grades.map do |grade_info| + selection = selection_lookup[grade_info[:category_id]] + next unless selection + + { + id: selection.id, + criterion_id: grade_info[:criterion_id], + grade: grade_info[:grade], + explanation: grade_info[:explanation] + } + end.compact + } + answer.assign_params(params) + end + + # Updates the answer's total grade based on the graded categories. + # @param [Course::Assessment::Answer::RubricBasedResponse] answer The answer to update. + # @param [Array] category_grades The processed category grades. + # @return [Integer] The new total grade for the answer. + def update_answer_grade(answer, category_grades) + grade_lookup = category_grades.to_h { |info| [info[:category_id], info[:grade]] } + total_grade = answer.selections.sum do |selection| + grade_lookup[selection.category_id] || selection.criterion&.grade || selection.grade || 0 + end + answer.grade = total_grade + total_grade + end + + # Builds a draft post with AI-generated feedback + # @param [Course::Assessment::SubmissionQuestion] submission_question The submission question + # @param [Course::Assessment::Answer] answer The answer + # @param [String] feedback The feedback text + # @return [Course::Discussion::Post] The built post + def build_draft_post(submission_question, answer, feedback) + submission_question.posts.build( + creator: User.system, + updater: User.system, + text: feedback, + is_ai_generated: true, + workflow_state: 'draft', + title: answer.submission.assessment.title + ) + end + + # Saves the draft post and updates the submission question + # @param [Course::Assessment::SubmissionQuestion] submission_question The submission question + # @param [Course::Discussion::Answer] answer The answer to associate with the post + # @param [Course::Discussion::Post] post The post to save + # @return [void] + def save_draft_post(submission_question, answer, post) + submission_question.class.transaction do + if submission_question.posts.length > 1 + post.parent = submission_question.posts.ordered_topologically.flatten.select(&:id).last + end + post.save! + submission_question.save! + create_topic_subscription(post.topic, answer) + post.topic.mark_as_pending + end + end + + # Creates a subscription for the discussion topic of the answer post + # @param [Course::Assessment::Answer] answer The answer to create the subscription for + # @param [Course::Discussion::Topic] discussion_topic The discussion topic to subscribe to + # @return [void] + def create_topic_subscription(discussion_topic, answer) + # Ensure the student who wrote the answer amd all group managers + # gets notified when someone comments on his answer + discussion_topic.ensure_subscribed_by(answer.submission.creator) + answer_course_user = answer.submission.course_user + answer_course_user.my_managers.each do |manager| + discussion_topic.ensure_subscribed_by(manager.user) + end + end + + # Creates AI-generated draft feedback post for the answer + # @param [Course::Assessment::Answer] answer The answer to create the post for + # @param [String] feedback The feedback text to include in the post + # @return [void] + def create_ai_generated_draft_post(answer, feedback) + submission_question = answer.submission.submission_questions.find_by(question_id: answer.question_id) + return unless submission_question + + post = build_draft_post(submission_question, answer, feedback) + save_draft_post(submission_question, answer, post) + end +end diff --git a/app/services/course/assessment/answer/rubric_llm_service.rb b/app/services/course/assessment/answer/rubric_llm_service.rb new file mode 100644 index 00000000000..ed7fd53ad24 --- /dev/null +++ b/app/services/course/assessment/answer/rubric_llm_service.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true +class Course::Assessment::Answer::RubricLlmService + @output_parser = Langchain::OutputParsers::StructuredOutputParser.from_json_schema( + JSON.parse( + File.read('app/services/course/assessment/answer/prompts/rubric_auto_grading_output_format.json') + ) + ) + @system_prompt = Langchain::Prompt.load_from_path( + file_path: 'app/services/course/assessment/answer/prompts/rubric_auto_grading_system_prompt.json' + ).format(format_instructions: @output_parser.get_format_instructions) + @user_prompt = Langchain::Prompt.load_from_path( + file_path: 'app/services/course/assessment/answer/prompts/rubric_auto_grading_user_prompt.json' + ) + + class << self + attr_reader :system_prompt, :user_prompt, :output_parser + end + + # Calls the LLM service to evaluate the answer. + # + # @param [Course::Assessment::Question::RubricBasedResponse] question The question to be graded. + # @param [Course::Assessment::Answer::RubricBasedResponse] answer The student's answer. + # @return [Hash] The LLM's evaluation response. + def evaluate(question, answer) + formatted_user_prompt = self.class.user_prompt.format( + question_title: question.title, + question_description: question.description, + rubric_categories: format_rubric_categories(question), + answer_text: answer.answer_text + ) + messages = [ + { role: 'system', content: self.class.system_prompt }, + { role: 'user', content: formatted_user_prompt } + ] + response = LANGCHAIN_OPENAI.chat( + messages: messages, + response_format: { type: 'json_object' } + ).completion + parse_llm_response(response) + end + + # Formats rubric categories for inclusion in the LLM prompt + # @param [Course::Assessment::Question::TextResponse] question The question containing rubric categories + # @return [String] Formatted string representation of rubric categories and criteria + def format_rubric_categories(question) + question.categories.map do |category| + criterions = category.criterions.map do |criterion| + "- [Grade: #{criterion.grade}, Criterion ID: #{criterion.id}]: #{criterion.explanation}" + end + <<~CATEGORY + Category ID: #{category.id} + Name: #{category.name} + Criteria: + #{criterions.join("\n")} + CATEGORY + end.join("\n\n") + end + + # Parses LLM response with retry logic for handling parsing failures + # @param [String] response The raw LLM response to parse + # @param [Hash] default_output The default grading output to return on failure + # @return [Hash] The parsed response as a structured hash + def parse_llm_response(response) + self.class.output_parser.parse(response) + rescue Langchain::OutputParsers::OutputParserException + fix_parser = Langchain::OutputParsers::OutputFixingParser.from_llm( + llm: LANGCHAIN_OPENAI, + parser: self.class.output_parser + ) + fix_parser.parse(response) + end +end diff --git a/app/views/course/assessment/answer/rubric_based_responses/_rubric_based_response.json.jbuilder b/app/views/course/assessment/answer/rubric_based_responses/_rubric_based_response.json.jbuilder index f31c32e4259..71fb9c66af5 100644 --- a/app/views/course/assessment/answer/rubric_based_responses/_rubric_based_response.json.jbuilder +++ b/app/views/course/assessment/answer/rubric_based_responses/_rubric_based_response.json.jbuilder @@ -11,6 +11,22 @@ json.fields do end last_attempt = last_attempt(answer) +attempt = answer.current_answer? ? last_attempt : answer + +job = attempt&.auto_grading&.job + +if job + json.autograding do + json.path job_path(job) if job.submitted? + json.partial! "jobs/#{job.status}", job: job + end +end + +if attempt.submitted? && !attempt.auto_grading + json.autograding do + json.status :submitted + end +end json.categoryGrades answer.selections do |selection| criterion = selection.criterion @@ -22,6 +38,14 @@ json.categoryGrades answer.selections do |selection| json.explanation criterion ? nil : selection.explanation end +posts = answer.submission.submission_questions.find_by(question_id: answer.question_id)&.discussion_topic&.posts +ai_generated_comment = posts&.select(&:is_ai_generated)&.last +if ai_generated_comment + json.aiGeneratedComment do + json.partial! ai_generated_comment + end +end + json.explanation do json.correct last_attempt&.correct json.explanations [] diff --git a/app/views/course/discussion/posts/_post.json.jbuilder b/app/views/course/discussion/posts/_post.json.jbuilder index 92cfbd20afc..296506dcd81 100644 --- a/app/views/course/discussion/posts/_post.json.jbuilder +++ b/app/views/course/discussion/posts/_post.json.jbuilder @@ -21,6 +21,8 @@ json.topicId post.topic_id json.canUpdate can?(:update, post) json.canDestroy can?(:destroy, post) json.isDelayed post.delayed? +json.workflowState post.workflow_state +json.isAiGenerated post.is_ai_generated if codaveri_feedback && codaveri_feedback.status == 'pending_review' json.codaveriFeedback do diff --git a/client/app/bundles/course/assessment/submission/actions/comments.js b/client/app/bundles/course/assessment/submission/actions/comments.js index 28e6c0cce44..f1abf69e0e8 100644 --- a/client/app/bundles/course/assessment/submission/actions/comments.js +++ b/client/app/bundles/course/assessment/submission/actions/comments.js @@ -1,4 +1,5 @@ import CourseAPI from 'api/course'; +import { POST_WORKFLOW_STATE } from 'lib/constants/sharedConstants'; import actionTypes from '../constants'; @@ -80,3 +81,23 @@ export function destroy(topicId, postId) { .catch(() => dispatch({ type: actionTypes.DELETE_COMMENT_FAILURE })); }; } + +export function publish(topicId, postId, text) { + const payload = { + discussion_post: { text, workflow_state: POST_WORKFLOW_STATE.published }, + }; + return (dispatch) => { + dispatch({ type: actionTypes.UPDATE_COMMENT_REQUEST }); + + return CourseAPI.comments + .update(topicId, postId, payload) + .then((response) => response.data) + .then((data) => { + dispatch({ + type: actionTypes.UPDATE_COMMENT_SUCCESS, + payload: data, + }); + }) + .catch(() => dispatch({ type: actionTypes.UPDATE_COMMENT_FAILURE })); + }; +} diff --git a/client/app/bundles/course/assessment/submission/actions/index.js b/client/app/bundles/course/assessment/submission/actions/index.js index 97bfcc8d729..3106313e8ad 100644 --- a/client/app/bundles/course/assessment/submission/actions/index.js +++ b/client/app/bundles/course/assessment/submission/actions/index.js @@ -1,3 +1,5 @@ +import { QuestionType } from 'types/course/assessment/question'; + import GlobalAPI from 'api'; import CourseAPI from 'api/course'; import { setNotification } from 'lib/actions'; @@ -26,6 +28,18 @@ export function getEvaluationResult(submissionId, answerId, questionId) { type: actionTypes.AUTOGRADE_SUCCESS, payload: { ...data, answerId }, }); + if (data.questionType === QuestionType.RubricBasedResponse) { + dispatch({ + type: actionTypes.AUTOGRADE_RUBRIC_SUCCESS, + payload: { + id: answerId, + questionId, + grading: data.grading, + categoryGrades: data.categoryGrades, + aiGeneratedComment: data.aiGeneratedComment, + }, + }); + } dispatch( historyActions.pushSingleAnswerItem({ questionId, diff --git a/client/app/bundles/course/assessment/submission/components/comment/CommentCard.jsx b/client/app/bundles/course/assessment/submission/components/comment/CommentCard.jsx index 670f2a8b70d..aaf665cee23 100644 --- a/client/app/bundles/course/assessment/submission/components/comment/CommentCard.jsx +++ b/client/app/bundles/course/assessment/submission/components/comment/CommentCard.jsx @@ -1,13 +1,22 @@ import { Component } from 'react'; import { defineMessages, FormattedMessage } from 'react-intl'; -import Delete from '@mui/icons-material/Delete'; -import Edit from '@mui/icons-material/Edit'; -import { Avatar, Button, CardHeader, Typography } from '@mui/material'; -import { grey, orange, red } from '@mui/material/colors'; +import { CheckCircleOutline } from '@mui/icons-material'; +import { + Avatar, + Button, + CardHeader, + IconButton, + Tooltip, + Typography, +} from '@mui/material'; +import { grey, orange } from '@mui/material/colors'; import PropTypes from 'prop-types'; +import DeleteButton from 'lib/components/core/buttons/DeleteButton'; +import EditButton from 'lib/components/core/buttons/EditButton'; import ConfirmationDialog from 'lib/components/core/dialogs/ConfirmationDialog'; import CKEditorRichText from 'lib/components/core/fields/CKEditorRichText'; +import { POST_WORKFLOW_STATE } from 'lib/constants/sharedConstants'; import { formatLongDateTime } from 'lib/moment'; import { postShape } from '../../propTypes'; @@ -25,6 +34,14 @@ const translations = defineMessages({ id: 'course.assessment.submission.comment.CommentCard.save', defaultMessage: 'Save', }, + publish: { + id: 'course.assessment.submission.comment.CommentCard.publish', + defaultMessage: 'Publish', + }, + isAiGenerated: { + id: 'course.assessment.submission.comment.CommentCard.isAiGenerated', + defaultMessage: 'AI Generated Comment', + }, }); const styles = { @@ -61,11 +78,6 @@ const styles = { marginRight: 5, marginBottom: 2, }, - headerButton: { - height: 35, - width: 40, - minWidth: 40, - }, headerButtonHidden: { height: 35, width: 40, @@ -115,6 +127,12 @@ export default class CommentCard extends Component { this.setState({ editMode: false }); } + onPublish() { + const { editValue } = this.props; + this.props.publishComment(editValue); + this.setState({ editMode: false }); + } + toggleEditMode() { const { editMode } = this.state; const { @@ -129,9 +147,11 @@ export default class CommentCard extends Component { const { editMode } = this.state; const { editValue, - post: { text, id }, + post: { text, id, workflowState }, } = this.props; + const isDraft = workflowState === POST_WORKFLOW_STATE.draft; + if (editMode) { return ( <> @@ -148,9 +168,15 @@ export default class CommentCard extends Component { > - + {isDraft ? ( + + ) : ( + + )} ); @@ -169,41 +195,66 @@ export default class CommentCard extends Component { canDestroy, id, isDelayed, + isAiGenerated, } = this.props.post; - const { isUpdatingAnnotationAllowed } = this.props; + const { post, isUpdatingAnnotationAllowed, editValue, publishComment } = + this.props; + + const isDraft = post.workflowState === POST_WORKFLOW_STATE.draft; return (
-
+
} + avatar={ + isAiGenerated && isDraft ? null : ( + + ) + } style={styles.cardHeader} subheader={`${formatLongDateTime(createdAt)}${ isDelayed ? ' (delayed comment)' : '' }`} subheaderTypographyProps={{ display: 'block' }} - title={name} - titleTypographyProps={{ display: 'block', marginright: 20 }} + title={ + isAiGenerated && isDraft ? ( + + ) : ( + name + ) + } + titleTypographyProps={{ + display: 'block', + marginRight: 20, + fontSize: '1.5rem', + }} />
+ {isDraft && ( + }> + publishComment(editValue)} + > + + + + )} {canUpdate && isUpdatingAnnotationAllowed ? ( - + /> ) : null} {canDestroy && isUpdatingAnnotationAllowed ? ( - + /> ) : null}
@@ -227,5 +278,6 @@ CommentCard.propTypes = { handleChange: PropTypes.func, updateComment: PropTypes.func, deleteComment: PropTypes.func, + publishComment: PropTypes.func, isUpdatingAnnotationAllowed: PropTypes.bool, }; diff --git a/client/app/bundles/course/assessment/submission/constants.ts b/client/app/bundles/course/assessment/submission/constants.ts index eb91ea57472..478b6f6cbd1 100644 --- a/client/app/bundles/course/assessment/submission/constants.ts +++ b/client/app/bundles/course/assessment/submission/constants.ts @@ -146,6 +146,7 @@ const actionTypes = mirrorCreator([ 'AUTOGRADE_REQUEST', 'AUTOGRADE_SUBMITTED', 'AUTOGRADE_SUCCESS', + 'AUTOGRADE_RUBRIC_SUCCESS', 'AUTOGRADE_FAILURE', 'AUTOGRADE_SAVING_SUCCESS', 'AUTOGRADE_SAVING_FAILURE', diff --git a/client/app/bundles/course/assessment/submission/containers/Comments.jsx b/client/app/bundles/course/assessment/submission/containers/Comments.jsx index 14100180d5b..2ad3c9c7016 100644 --- a/client/app/bundles/course/assessment/submission/containers/Comments.jsx +++ b/client/app/bundles/course/assessment/submission/containers/Comments.jsx @@ -4,6 +4,7 @@ import { connect } from 'react-redux'; import { Typography } from '@mui/material'; import PropTypes from 'prop-types'; +import { POST_WORKFLOW_STATE } from 'lib/constants/sharedConstants'; import toast from 'lib/hooks/toast'; import * as commentActions from '../actions/comments'; @@ -28,6 +29,7 @@ class VisibleComments extends Component { createComment, updateComment, deleteComment, + publishComment, graderView, renderDelayedCommentButton, } = this.props; @@ -40,7 +42,9 @@ class VisibleComments extends Component { {posts.map( (post) => - (graderView || !post.isDelayed) && ( + (graderView || + (!post.isDelayed && + post.workflowState !== POST_WORKFLOW_STATE.draft)) && ( deleteComment(post.id)} @@ -48,6 +52,7 @@ class VisibleComments extends Component { handleChange={(value) => handleUpdateChange(post.id, value)} isUpdatingAnnotationAllowed post={post} + publishComment={(value) => publishComment(post.id, value)} updateComment={(value) => updateComment(post.id, value)} /> ), @@ -86,6 +91,7 @@ VisibleComments.propTypes = { createComment: PropTypes.func.isRequired, updateComment: PropTypes.func.isRequired, deleteComment: PropTypes.func.isRequired, + publishComment: PropTypes.func.isRequired, }; function mapStateToProps({ assessments: { submission } }, ownProps) { @@ -127,6 +133,8 @@ function mapDispatchToProps(dispatch, ownProps) { dispatch(commentActions.update(topic.id, postId, comment)), deleteComment: (postId) => dispatch(commentActions.destroy(topic.id, postId)), + publishComment: (postId, comment) => + dispatch(commentActions.publish(topic.id, postId, comment)), }; } diff --git a/client/app/bundles/course/assessment/submission/containers/RubricExplanation.tsx b/client/app/bundles/course/assessment/submission/containers/RubricExplanation.tsx index 1ef226fa0de..ce774831172 100644 --- a/client/app/bundles/course/assessment/submission/containers/RubricExplanation.tsx +++ b/client/app/bundles/course/assessment/submission/containers/RubricExplanation.tsx @@ -1,4 +1,4 @@ -import { Dispatch, FC, SetStateAction } from 'react'; +import { FC } from 'react'; import { MenuItem, Select, Typography } from '@mui/material'; import { AnswerRubricGradeData } from 'types/course/assessment/question/rubric-based-responses'; import { @@ -15,7 +15,9 @@ import { } from '../actions/answers'; import { workflowStates } from '../constants'; import { getQuestionWithGrades } from '../selectors/grading'; +import { getQuestionFlags } from '../selectors/questionFlags'; import { getQuestions } from '../selectors/questions'; +import { getSubmissionFlags } from '../selectors/submissionFlags'; import { getSubmission } from '../selectors/submissions'; import { GradeWithPrefilledStatus } from '../types'; import { transformRubric } from '../utils/rubrics'; @@ -25,9 +27,6 @@ interface RubricExplanationProps { questionId: number; category: RubricBasedResponseCategoryQuestionData; categoryGrades: Record; - setCategoryGrades: Dispatch< - SetStateAction> - >; setIsFirstRendering: (isFirstRendering: boolean) => void; updateGrade: ( catGrades: Record, @@ -42,7 +41,6 @@ const RubricExplanation: FC = (props) => { questionId, category, categoryGrades, - setCategoryGrades, setIsFirstRendering, updateGrade, } = props; @@ -58,6 +56,10 @@ const RubricExplanation: FC = (props) => { const questions = useAppSelector(getQuestions); const question = questions[questionId] as SubmissionQuestionBaseData; + const questionFlags = useAppSelector(getQuestionFlags); + const submissionFlags = useAppSelector(getSubmissionFlags); + const isAutograding = + submissionFlags?.isAutograding || questionFlags[questionId]?.isAutograding; const isNotGradedAndNotPublished = workflowState !== workflowStates.Graded && workflowState !== workflowStates.Published; @@ -97,7 +99,6 @@ const RubricExplanation: FC = (props) => { const finalGrade = Math.max(0, Math.min(totalGrade, question.maximumGrade)); - setCategoryGrades(newCategoryGrades); setIsFirstRendering(false); dispatch(updateRubric(answerId, transformRubric(newCategoryGrades))); @@ -112,6 +113,7 @@ const RubricExplanation: FC = (props) => { return ( = (props) => { return (