From 9e0ed0da46c89421acffd95018f79b2e976ec2e3 Mon Sep 17 00:00:00 2001 From: craigtonlian Date: Fri, 30 May 2025 15:13:54 +0800 Subject: [PATCH 1/3] feat(LiveFeedbackStatistics): add metric selector to Live Feedback statistics --- .../statistics/assessments_controller.rb | 117 +++- .../codaveri_activities.json.jbuilder | 22 + .../live_feedback_statistics.json.jbuilder | 4 +- .../api/course/Statistics/CourseStatistics.ts | 1 + .../LiveFeedbackStatisticsTable.tsx | 518 ++++++++++++------ .../AssessmentStatistics/classNameUtils.ts | 6 +- .../LiveFeedbackMetricsSelector.tsx | 45 ++ .../AssessmentStatistics/translations.ts | 34 +- .../submission/actions/answers/index.js | 5 +- .../SubmissionEditIndex/SubmissionForm.tsx | 5 +- .../codaveri/CodaveriStatistics.tsx | 17 + .../codaveri/CodaveriStatisticsTable.tsx | 237 ++++++++ .../course/statistics/assessmentStatistics.ts | 11 +- .../statistics/assessment_controller_spec.rb | 12 +- 14 files changed, 823 insertions(+), 211 deletions(-) create mode 100644 app/views/course/statistics/aggregate/codaveri_activities.json.jbuilder create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/components/LiveFeedbackMetricsSelector.tsx create mode 100644 client/app/bundles/course/statistics/pages/StatisticsIndex/codaveri/CodaveriStatistics.tsx create mode 100644 client/app/bundles/course/statistics/pages/StatisticsIndex/codaveri/CodaveriStatisticsTable.tsx diff --git a/app/controllers/course/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index cc7afe09e58..3e758a145b2 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -108,24 +108,108 @@ def create_question_related_hash end def create_student_live_feedback_hash - count_hash = Course::Assessment::LiveFeedback::Message.joins(:thread). - select('live_feedback_threads.submission_creator_id', - 'live_feedback_threads.submission_question_id'). - where.not(creator_id: User::SYSTEM_USER_ID). - where(live_feedback_threads: { - submission_question_id: @submission_question_id_hash.values, - submission_creator_id: @all_students.pluck(:user_id) - }). - group('live_feedback_threads.submission_creator_id', - 'live_feedback_threads.submission_question_id').count - submission_hash = @submissions.to_h { |submission| [submission.creator_id, submission] } + answer_hash = fetch_current_answer_hash + message_hash = fetch_live_feedback_message_hash + prompt_hash = calculate_prompt_hash(message_hash) + submission_hash = @submissions.index_by(&:creator_id) + @student_live_feedback_hash = @all_students.to_h do |student| submission = submission_hash[student.user_id] - live_feedback_count = get_live_feedback_count(count_hash, submission) - [student, [submission, live_feedback_count]] + live_feedback_data = build_live_feedback_data(submission, answer_hash, message_hash, prompt_hash) + + [student, [submission, live_feedback_data]] + end + end + + def fetch_current_answer_hash + Course::Assessment::Answer.where( + submission_id: @submissions.pluck(:id), + current_answer: true + ).to_h { |answer| [[answer.submission_id, answer.question_id], answer&.grade&.to_f || 0] } + end + + def fetch_live_feedback_message_hash + Course::Assessment::LiveFeedback::Message. + joins(:thread). + where(live_feedback_threads: { + submission_question_id: @submission_question_id_hash.values, + submission_creator_id: @all_students.pluck(:user_id) + }). + where.not(creator_id: User::SYSTEM_USER_ID). + select('live_feedback_threads.submission_creator_id', + 'live_feedback_threads.submission_question_id', + 'live_feedback_messages.created_at', + 'live_feedback_messages.content'). + order(:created_at). + group_by do |message| + [message.submission_creator_id, message.submission_question_id] + end + end + + def build_live_feedback_data(submission, answer_hash, message_hash, prompt_hash) + @ordered_questions.map do |question_id| + submission_question_id = @submission_question_id_hash[[submission&.id, question_id]] + key = [submission&.creator_id, submission_question_id] + + prompt_data = prompt_hash[key] || { prompt_count: 0, prompt_length: 0 } + messages = message_hash[key] || [] + answer = answer_hash[[submission&.id, question_id]] + + { + grade: answer, + grade_diff: calculate_grade_diff(submission, question_id, messages), + prompt_length: prompt_data[:prompt_length], + prompt_count: prompt_data[:prompt_count] + } end end + def calculate_prompt_hash(message_hash) + message_hash.transform_values do |messages| + { + prompt_count: messages.size, + prompt_length: messages.sum { |m| m.content.split(/\s+/).size } + } + end + end + + def calculate_grade_diff(submission, question_id, messages) + return 0 unless submission && messages.any? + + first_message_time = messages.first.created_at + last_message_time = messages.last.created_at + + answer_before = fetch_answer_before(submission, question_id, first_message_time) + answer_after = fetch_answer_after(submission, question_id, last_message_time) + + return 0 unless answer_after && answer_before + + (answer_after.grade.to_f - answer_before.grade.to_f).round(2) + end + + def fetch_answer_before(submission, question_id, timestamp) + Course::Assessment::Answer. + where(submission_id: submission.id, question_id: question_id). + where('created_at < ?', timestamp). + order(:created_at). + last + end + + def fetch_answer_after(submission, question_id, timestamp) + Course::Assessment::Answer. + where(submission_id: submission.id, question_id: question_id). + where('created_at > ?', timestamp). + order(:created_at). + first + end + + def fetch_messages_for_question(submission_question_id) + Course::Assessment::LiveFeedback::Message. + joins(:thread). + where(live_feedback_threads: { submission_question_id: submission_question_id }). + order(:created_at) + end + def create_question_order_hash @question_order_hash = @assessment.question_assessments.to_h do |q| [q.question_id, q.weight] @@ -140,11 +224,4 @@ def create_submission_question_id_hash(questions) [[sq.submission_id, sq.question_id], sq.id] end end - - def get_live_feedback_count(count_hash, submission) - @ordered_questions.map do |question_id| - submission_question_id = @submission_question_id_hash[[submission&.id, question_id]] - count_hash[[submission&.creator_id, submission_question_id]] || 0 - end - end end diff --git a/app/views/course/statistics/aggregate/codaveri_activities.json.jbuilder b/app/views/course/statistics/aggregate/codaveri_activities.json.jbuilder new file mode 100644 index 00000000000..a209ba9b674 --- /dev/null +++ b/app/views/course/statistics/aggregate/codaveri_activities.json.jbuilder @@ -0,0 +1,22 @@ +# frozen_string_literal: true +json.courseId current_course.id +json.liveFeedbacks @live_feedbacks do |feedback| + course_user = @course_user_hash[feedback.submission_creator_id] + + json.id feedback.id + json.userId course_user&.id + json.submissionId feedback.submission_id + json.assessmentId feedback.assessment_id + json.questionId feedback.question_id + + json.name course_user&.name + json.nameLink course_user_path(current_course, course_user) + + json.lastMessage feedback.content + json.messageCount feedback.message_count + json.questionNumber @assessment_question_hash[[feedback.assessment_id, feedback.question_id]][:question_number] + json.questionTitle @assessment_question_hash[[feedback.assessment_id, feedback.question_id]][:question_title] + json.assessmentTitle @assessment_question_hash[[feedback.assessment_id, feedback.question_id]][:assessment_title] + + json.createdAt feedback.created_at&.iso8601 +end diff --git a/app/views/course/statistics/assessments/live_feedback_statistics.json.jbuilder b/app/views/course/statistics/assessments/live_feedback_statistics.json.jbuilder index 9f7d10f80aa..bd8038f714f 100644 --- a/app/views/course/statistics/assessments/live_feedback_statistics.json.jbuilder +++ b/app/views/course/statistics/assessments/live_feedback_statistics.json.jbuilder @@ -1,5 +1,5 @@ # frozen_string_literal: true -json.array! @student_live_feedback_hash.each do |course_user, (submission, live_feedback_count)| +json.array! @student_live_feedback_hash.each do |course_user, (submission, live_feedback_data)| json.partial! 'course_user', course_user: course_user if submission.nil? json.workflowState 'unstarted' @@ -13,6 +13,6 @@ json.array! @student_live_feedback_hash.each do |course_user, (submission, live_ json.name name end - json.liveFeedbackCount live_feedback_count + json.liveFeedbackData live_feedback_data json.questionIds(@question_order_hash.keys.sort_by { |key| @question_order_hash[key] }) end diff --git a/client/app/api/course/Statistics/CourseStatistics.ts b/client/app/api/course/Statistics/CourseStatistics.ts index ee79a79721e..4ec9d55cb21 100644 --- a/client/app/api/course/Statistics/CourseStatistics.ts +++ b/client/app/api/course/Statistics/CourseStatistics.ts @@ -3,6 +3,7 @@ import { JobSubmitted } from 'types/jobs'; import { APIResponse } from 'api/types'; import { AssessmentsStatistics, + CodaveriStatistics, CoursePerformanceStatistics, CourseProgressionStatistics, GetHelpStatistics, diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatisticsTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatisticsTable.tsx index 1aa8242d286..549766dbc9a 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatisticsTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatisticsTable.tsx @@ -1,7 +1,10 @@ -import { FC, ReactNode, useEffect, useState } from 'react'; +import { FC, ReactNode, useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; -import { Box, Typography } from '@mui/material'; -import { AssessmentLiveFeedbackStatistics } from 'types/course/statistics/assessmentStatistics'; +import { Box, Tooltip, Typography } from '@mui/material'; +import { + AssessmentLiveFeedbackData, + AssessmentLiveFeedbackStatistics, +} from 'types/course/statistics/assessmentStatistics'; import SubmissionWorkflowState from 'course/assessment/submission/components/SubmissionWorkflowState'; import { workflowStates } from 'course/assessment/submission/constants'; @@ -14,12 +17,36 @@ import { getEditSubmissionURL } from 'lib/helpers/url-builders'; import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; +import LiveFeedbackMetricSelector from './components/LiveFeedbackMetricsSelector'; import { getClassnameForLiveFeedbackCell } from './classNameUtils'; import LiveFeedbackHistoryContent from './LiveFeedbackHistory'; import { getAssessmentStatistics } from './selectors'; import translations from './translations'; import { getJointGroupsName } from './utils'; +const METRIC_CONFIG = { + grade: { + showTotal: true, + legendLowerLabel: 'legendLowerLabelGrade', + legendUpperLabel: 'legendUpperLabelGrade', + }, + grade_diff: { + showTotal: false, + legendLowerLabel: 'legendLowerLabelGradeDiff', + legendUpperLabel: 'legendUpperLabelGradeDiff', + }, + prompt_count: { + showTotal: true, + legendLowerLabel: 'legendLowerLabelPromptCount', + legendUpperLabel: 'legendUpperLabelPromptCount', + }, + prompt_length: { + showTotal: true, + legendLowerLabel: 'legendLowerLabelPromptLength', + legendUpperLabel: 'legendUpperLabelPromptLength', + }, +} as const; + interface Props { includePhantom: boolean; liveFeedbackStatistics: AssessmentLiveFeedbackStatistics[]; @@ -37,225 +64,376 @@ const LiveFeedbackStatisticsTable: FC = (props) => { const [parsedStatistics, setParsedStatistics] = useState< AssessmentLiveFeedbackStatistics[] >([]); - const [upperQuartileFeedbackCount, setUpperQuartileFeedbackCount] = + const [upperQuartileMetricValue, setUpperQuartileMetricValue] = useState(0); - const [openLiveFeedbackHistory, setOpenLiveFeedbackHistory] = useState(false); const [liveFeedbackInfo, setLiveFeedbackInfo] = useState({ courseUserId: 0, questionId: 0, questionNumber: 0, }); + const [selectedMetric, setSelectedMetric] = useState({ + value: 'prompt_count', + label: 'Prompt Count', + }); useEffect(() => { - const feedbackCounts = liveFeedbackStatistics - .flatMap((s) => s.liveFeedbackCount ?? []) - .map((c) => c ?? 0) + // Create a deep copy of the statistics to avoid mutating the original data + const processedStats = liveFeedbackStatistics.map((stat) => ({ + ...stat, + liveFeedbackData: stat.liveFeedbackData.map((data) => ({ + ...data, + [selectedMetric.value]: data[selectedMetric.value as keyof typeof data], + })), + })); + + // Calculate quartile value from all non-zero values + const feedbackStats = processedStats + .flatMap((s) => + s.liveFeedbackData.map((d) => { + const val = d[selectedMetric.value as keyof typeof d]; + return typeof val === 'number' ? val : 0; + }), + ) .filter((c) => c !== 0) .sort((a, b) => a - b); + const upperQuartilePercentileIndex = Math.floor( - 0.75 * (feedbackCounts.length - 1), + 0.75 * (feedbackStats.length - 1), ); const upperQuartilePercentileValue = - feedbackCounts[upperQuartilePercentileIndex]; - setUpperQuartileFeedbackCount(upperQuartilePercentileValue); + feedbackStats[upperQuartilePercentileIndex]; + setUpperQuartileMetricValue(upperQuartilePercentileValue); const filteredStats = includePhantom - ? liveFeedbackStatistics - : liveFeedbackStatistics.filter((s) => !s.courseUser.isPhantom); + ? processedStats + : processedStats.filter((s) => !s.courseUser.isPhantom); + + // Only calculate totals if the metric should show them + if ( + METRIC_CONFIG[selectedMetric.value as keyof typeof METRIC_CONFIG] + ?.showTotal + ) { + filteredStats.forEach((stat) => { + stat.totalMetricCount = stat.liveFeedbackData.reduce((sum, data) => { + const value = data[selectedMetric.value as keyof typeof data]; + return sum + (typeof value === 'number' ? value : 0); + }, 0); + }); + } else { + // Clear any existing totals + filteredStats.forEach((stat) => { + stat.totalMetricCount = undefined; + }); + } - filteredStats.forEach((stat) => { - stat.totalFeedbackCount = - stat.liveFeedbackCount?.reduce((sum, count) => sum + (count || 0), 0) ?? - 0; + const sortedStats = filteredStats.sort((a, b) => { + // First sort by phantom status + const phantomDiff = + Number(a.courseUser.isPhantom) - Number(b.courseUser.isPhantom); + if (phantomDiff !== 0) return phantomDiff; + + // Then sort by workflow state + const workflowStateOrder = { + [workflowStates.Published]: 0, + [workflowStates.Graded]: 1, + [workflowStates.Submitted]: 2, + [workflowStates.Attempting]: 3, + [workflowStates.Unstarted]: 4, + }; + const stateA = + workflowStateOrder[a.workflowState ?? workflowStates.Unstarted] ?? 5; + const stateB = + workflowStateOrder[b.workflowState ?? workflowStates.Unstarted] ?? 5; + if (stateA !== stateB) return stateA - stateB; + + // Then sort by total metric count + const feedbackDiff = + (b.totalMetricCount ?? 0) - (a.totalMetricCount ?? 0); + if (feedbackDiff !== 0) return feedbackDiff; + + // Finally sort by name + return a.courseUser.name.localeCompare(b.courseUser.name); }); - setParsedStatistics( - filteredStats.sort((a, b) => { - const phantomDiff = - Number(a.courseUser.isPhantom) - Number(b.courseUser.isPhantom); - if (phantomDiff !== 0) return phantomDiff; + setParsedStatistics(sortedStats); + }, [liveFeedbackStatistics, includePhantom, selectedMetric]); - const feedbackDiff = - (b.totalFeedbackCount ?? 0) - (a.totalFeedbackCount ?? 0); - if (feedbackDiff !== 0) return feedbackDiff; + const renderTooltipContent = ( + liveFeedbackData: AssessmentLiveFeedbackData, + ): ReactNode => ( + + + Grade: {liveFeedbackData.grade ?? '-'} + + + Grade Difference: {liveFeedbackData.grade_diff ?? '-'} + + + Prompt Count: {liveFeedbackData.prompt_count ?? '-'} + + + Prompt Length: {liveFeedbackData.prompt_length ?? '-'} + + + ); - return a.courseUser.name.localeCompare(b.courseUser.name); - }), - ); - }, [liveFeedbackStatistics, includePhantom]); + const renderClickableCell = ( + metricValue: number, + classname: string, + courseUserId: number, + questionId: number, + questionNumber: number, + ): JSX.Element => ( +
{ + setOpenLiveFeedbackHistory(true); + setLiveFeedbackInfo({ courseUserId, questionId, questionNumber }); + }} + > + {metricValue} +
+ ); // the case where the live feedback count is null is handled separately inside the column // (refer to the definition of statColumns below) const renderNonNullClickableLiveFeedbackCountCell = ( - count: number, + metricValue: number, courseUserId: number, questionId: number, questionNumber: number, + liveFeedbackData: AssessmentLiveFeedbackData, ): ReactNode => { const classname = getClassnameForLiveFeedbackCell( - count, - upperQuartileFeedbackCount, + metricValue, + upperQuartileMetricValue, ); - if (count === 0) { - return {count}; + + const tooltipContent = renderTooltipContent(liveFeedbackData); + + // If there is no LiveFeedbackHistory, we do not show the clickable cell + if (liveFeedbackData.prompt_count === 0) { + return ( +
+ + {metricValue} + +
+ ); } + return ( -
{ - setOpenLiveFeedbackHistory(true); - setLiveFeedbackInfo({ courseUserId, questionId, questionNumber }); - }} - > - {count} -
+ + {renderClickableCell( + metricValue, + classname, + courseUserId, + questionId, + questionNumber, + )} + ); }; - const statColumns: ColumnTemplate[] = - Array.from({ length: assessment?.questionCount ?? 0 }, (_, index) => { - return { + const columns: ColumnTemplate[] = + useMemo(() => { + const statColumns = Array.from( + { length: assessment?.questionCount ?? 0 }, + (_, index) => { + return { + searchProps: { + getValue: (datum) => + datum.liveFeedbackData[index]?.[ + selectedMetric.value as keyof (typeof datum.liveFeedbackData)[number] + ]?.toString() ?? '', + }, + title: t(translations.questionIndex, { index: index + 1 }), + cell: (datum): ReactNode => { + const metricValue = + datum.liveFeedbackData[index]?.[ + selectedMetric.value as keyof (typeof datum.liveFeedbackData)[number] + ]; + return typeof metricValue === 'number' + ? renderNonNullClickableLiveFeedbackCountCell( + metricValue, + datum.courseUser.id, + datum.questionIds[index], + index + 1, + datum.liveFeedbackData[index], + ) + : null; + }, + sortable: true, + csvDownloadable: true, + className: 'text-right', + sortProps: { + sort: (a, b): number => { + const aValue = + a.liveFeedbackData[index]?.[ + selectedMetric.value as keyof (typeof a.liveFeedbackData)[number] + ] ?? Number.MIN_SAFE_INTEGER; + const bValue = + b.liveFeedbackData[index]?.[ + selectedMetric.value as keyof (typeof b.liveFeedbackData)[number] + ] ?? Number.MIN_SAFE_INTEGER; + return aValue - bValue; + }, + }, + }; + }, + ); + + const baseColumns: ColumnTemplate[] = [ + { + searchProps: { + getValue: (datum) => datum.courseUser.name, + }, + title: t(translations.name), + sortable: true, + searchable: true, + cell: (datum) => ( +
+ + {datum.courseUser.name} + + {datum.courseUser.isPhantom && ( + + )} +
+ ), + csvDownloadable: true, + }, + { + searchProps: { + getValue: (datum) => datum.courseUser.email, + }, + title: t(translations.email), + hidden: true, + cell: (datum) => ( +
+ {datum.courseUser.email} +
+ ), + csvDownloadable: true, + }, + { + of: 'groups', + title: t(translations.group), + sortable: true, + searchable: true, + searchProps: { + getValue: (datum) => getJointGroupsName(datum.groups), + }, + cell: (datum) => getJointGroupsName(datum.groups), + csvDownloadable: true, + }, + { + of: 'workflowState', + title: t(translations.workflowState), + sortable: true, + cell: (datum) => ( + + ), + className: 'center', + }, + ...statColumns, + ]; + + // Always add total column, but make it empty when showTotal is false to prevent UI elements shifting + baseColumns.push({ searchProps: { getValue: (datum) => - datum.liveFeedbackCount?.[index]?.toString() ?? '', + METRIC_CONFIG[selectedMetric.value as keyof typeof METRIC_CONFIG] + ?.showTotal + ? datum.liveFeedbackData + .reduce((sum, data) => { + const value = + data[selectedMetric.value as keyof typeof data]; + return sum + (typeof value === 'number' ? value : 0); + }, 0) + .toString() + : '', }, - title: t(translations.questionIndex, { index: index + 1 }), + title: t(translations.total), cell: (datum): ReactNode => { - return typeof datum.liveFeedbackCount?.[index] === 'number' - ? renderNonNullClickableLiveFeedbackCountCell( - datum.liveFeedbackCount?.[index], - datum.courseUser.id, - datum.questionIds[index], - index + 1, - ) - : null; + if ( + !METRIC_CONFIG[selectedMetric.value as keyof typeof METRIC_CONFIG] + ?.showTotal + ) { + return -; + } + + const totalMetricValue = datum.liveFeedbackData.reduce( + (sum, data) => { + const value = data[selectedMetric.value as keyof typeof data]; + return sum + (typeof value === 'number' ? value : 0); + }, + 0, + ); + + return {totalMetricValue}; }, sortable: true, csvDownloadable: true, className: 'text-right', sortProps: { sort: (a, b): number => { - const aValue = - a.liveFeedbackCount?.[index] ?? Number.MIN_SAFE_INTEGER; - const bValue = - b.liveFeedbackCount?.[index] ?? Number.MIN_SAFE_INTEGER; - - return aValue - bValue; + if ( + !METRIC_CONFIG[selectedMetric.value as keyof typeof METRIC_CONFIG] + ?.showTotal + ) { + return 0; + } + const totalA = a.totalMetricCount ?? 0; + const totalB = b.totalMetricCount ?? 0; + return totalA - totalB; }, }, - }; - }); + }); - const columns: ColumnTemplate[] = [ - { - searchProps: { - getValue: (datum) => datum.courseUser.name, - }, - title: t(translations.name), - sortable: true, - searchable: true, - cell: (datum) => ( -
- - {datum.courseUser.name} - - {datum.courseUser.isPhantom && ( - - )} -
- ), - csvDownloadable: true, - }, - { - searchProps: { - getValue: (datum) => datum.courseUser.email, - }, - title: t(translations.email), - hidden: true, - cell: (datum) => ( -
{datum.courseUser.email}
- ), - csvDownloadable: true, - }, - { - of: 'groups', - title: t(translations.group), - sortable: true, - searchable: true, - searchProps: { - getValue: (datum) => getJointGroupsName(datum.groups), - }, - cell: (datum) => getJointGroupsName(datum.groups), - csvDownloadable: true, - }, - { - of: 'workflowState', - title: t(translations.workflowState), - sortable: true, - cell: (datum) => ( - - ), - className: 'center', - }, - ...statColumns, - { - searchProps: { - getValue: (datum) => - datum.liveFeedbackCount - ? datum.liveFeedbackCount - .reduce((sum, count) => sum + (count || 0), 0) - .toString() - : '', - }, - title: t(translations.total), - cell: (datum): ReactNode => { - const totalFeedbackCount = datum.liveFeedbackCount - ? datum.liveFeedbackCount.reduce( - (sum, count) => sum + (count || 0), - 0, - ) - : null; - return ( -
- {totalFeedbackCount} -
- ); - }, - sortable: true, - csvDownloadable: true, - className: 'text-right', - sortProps: { - sort: (a, b): number => { - const totalA = a.totalFeedbackCount ?? 0; - const totalB = b.totalFeedbackCount ?? 0; - return totalA - totalB; - }, - }, - }, - ]; + return baseColumns; + }, [selectedMetric.value, upperQuartileMetricValue]); return ( <> -
- - {t(translations.legendLowerUsage)} - - { - // The gradient color bar -
- } - - {t(translations.legendHigherusage)} - +
+
+ +
+ +
+ + {t( + translations[ + METRIC_CONFIG[ + selectedMetric.value as keyof typeof METRIC_CONFIG + ].legendLowerLabel + ], + )} + +
+ + {t( + translations[ + METRIC_CONFIG[ + selectedMetric.value as keyof typeof METRIC_CONFIG + ].legendUpperLabel + ], + )} + +
{ const gradientLevel = calculateOneSidedColorGradientLevel( - count, + metricValue, upperQuartile, ); - return `${redBackgroundColorClassName[gradientLevel]} p-[1rem]`; + return `${redBackgroundColorClassName[gradientLevel]} p-1.5`; }; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/components/LiveFeedbackMetricsSelector.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/components/LiveFeedbackMetricsSelector.tsx new file mode 100644 index 00000000000..fe3713129b1 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/components/LiveFeedbackMetricsSelector.tsx @@ -0,0 +1,45 @@ +import { FC } from 'react'; +import { Autocomplete, Box, TextField } from '@mui/material'; + +interface MetricOption { + value: string; + label: string; +} + +interface Props { + selectedMetric: MetricOption; + setSelectedMetric: (value: MetricOption) => void; +} + +const metricOptions: MetricOption[] = [ + { value: 'grade', label: 'Grade' }, + { value: 'grade_diff', label: 'Grade Difference' }, + { value: 'prompt_count', label: 'Prompt Count' }, + { value: 'prompt_length', label: 'Prompt Length' }, +]; + +const LiveFeedbackMetricSelector: FC = ({ + selectedMetric, + setSelectedMetric, +}) => { + return ( + + option.label} + isOptionEqualToValue={(option, value) => option.value === value.value} + onChange={(_, value) => { + if (value) setSelectedMetric(value); + }} + options={metricOptions} + renderInput={(params) => ( + + )} + value={selectedMetric} + /> + + ); +}; + +export default LiveFeedbackMetricSelector; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/translations.ts b/client/app/bundles/course/assessment/pages/AssessmentStatistics/translations.ts index 20d520114fe..42709206043 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/translations.ts +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/translations.ts @@ -33,14 +33,38 @@ const translations = defineMessages({ id: 'course.assessment.statistics.group', defaultMessage: 'Group', }, - legendHigherusage: { - id: 'course.assessment.statistics.legendHigherusage', - defaultMessage: 'Higher Usage', + legendLowerLabelGrade: { + id: 'course.assessment.statistics.legendLowerLabelGrade', + defaultMessage: 'Lower Grade', + }, + legendUpperLabelGrade: { + id: 'course.assessment.statistics.legendHigherLabelGrade', + defaultMessage: 'Higher Grade', + }, + legendLowerLabelGradeDiff: { + id: 'course.assessment.statistics.legendLowerLabelGradeDiff', + defaultMessage: 'Lower Grade Difference', + }, + legendUpperLabelGradeDiff: { + id: 'course.assessment.statistics.legendHigherLabelGradeDiff', + defaultMessage: 'Higher Grade Difference', }, - legendLowerUsage: { - id: 'course.assessment.statistics.legendLowerUsage', + legendLowerLabelPromptCount: { + id: 'course.assessment.statistics.legendLowerLabelPromptCount', defaultMessage: 'Lower Usage', }, + legendUpperLabelPromptCount: { + id: 'course.assessment.statistics.legendUpperLabelPromptCount', + defaultMessage: 'Higher Usage', + }, + legendLowerLabelPromptLength: { + id: 'course.assessment.statistics.legendLowerLabelPromptLength', + defaultMessage: 'Lower Word Count', + }, + legendUpperLabelPromptLength: { + id: 'course.assessment.statistics.legendUpperLabelPromptLength', + defaultMessage: 'Higher Word Count', + }, liveFeedbackFilename: { id: 'course.assessment.statistics.liveFeedback.filename', defaultMessage: 'Question-level Live Feedback Statistics for {assessment}', diff --git a/client/app/bundles/course/assessment/submission/actions/answers/index.js b/client/app/bundles/course/assessment/submission/actions/answers/index.js index d20fe67149c..e08601f23c5 100644 --- a/client/app/bundles/course/assessment/submission/actions/answers/index.js +++ b/client/app/bundles/course/assessment/submission/actions/answers/index.js @@ -145,11 +145,12 @@ export function saveAnswer(answerData, answerId, currentTime, resetField) { return (dispatch, getState) => { dispatch(dispatchUpdateAnswerFlagSavingSize(answerId, savingSize)); + // When the current client version is greater than that in the redux store, // the answer is already stale and no API call is needed. const isAnswerStale = getClientVersionForAnswerId(getState(), answerId) > currentTime; - if (isAnswerStale) return {}; + if (isAnswerStale) return Promise.resolve({}); dispatch({ type: actionTypes.SAVE_ANSWER_REQUEST, @@ -182,6 +183,7 @@ export function saveAnswer(answerData, answerId, currentTime, resetField) { dispatch( dispatchUpdateAnswerFlagSavingStatus(answerId, SAVING_STATUS.Saved), ); + return Promise.resolve({}); }) .catch((e) => { dispatch({ @@ -196,6 +198,7 @@ export function saveAnswer(answerData, answerId, currentTime, resetField) { isStaleAnswer, ), ); + return Promise.reject(e); }); }; } diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionForm.tsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionForm.tsx index 01521833fe8..98eede7b92f 100644 --- a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionForm.tsx +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionForm.tsx @@ -152,8 +152,9 @@ const SubmissionForm: FC = (props) => { setStepIndex(assignedStep); if (scrollToRef.current) { - setImmediate(() => - (scrollToRef.current! as HTMLElement).scrollIntoView(), + setTimeout( + () => (scrollToRef.current! as HTMLElement).scrollIntoView(), + 0, ); } } diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/codaveri/CodaveriStatistics.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/codaveri/CodaveriStatistics.tsx new file mode 100644 index 00000000000..7b81886562a --- /dev/null +++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/codaveri/CodaveriStatistics.tsx @@ -0,0 +1,17 @@ +import { FC } from 'react'; + +import { fetchCodaveriStatistics } from 'course/statistics/operations'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import Preload from 'lib/components/wrappers/Preload'; + +import CodaveriStatisticsTable from './CodaveriStatisticsTable'; + +const CodaveriStatistics: FC = () => { + return ( + } while={fetchCodaveriStatistics}> + {(data) => } + + ); +}; + +export default CodaveriStatistics; diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/codaveri/CodaveriStatisticsTable.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/codaveri/CodaveriStatisticsTable.tsx new file mode 100644 index 00000000000..6471267988f --- /dev/null +++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/codaveri/CodaveriStatisticsTable.tsx @@ -0,0 +1,237 @@ +import { FC, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { useParams } from 'react-router-dom'; +import { Tooltip, Typography } from '@mui/material'; + +import { CodaveriLiveFeedbackActivity } from 'course/statistics/types'; +import Link from 'lib/components/core/Link'; +import { ColumnTemplate } from 'lib/components/table'; +import Table from 'lib/components/table/Table'; +import { + DEFAULT_TABLE_ROWS_PER_PAGE, + NUM_CELL_CLASS_NAME, +} from 'lib/constants/sharedConstants'; +import { + getEditSubmissionQuestionURL, + getEditSubmissionURL, +} from 'lib/helpers/url-builders'; +import assessmentStatisticsTranslations from '../../../../assessment/pages/AssessmentStatistics/translations'; +import useTranslation from 'lib/hooks/useTranslation'; +import { formatMiniDateTime } from 'lib/moment'; +import Prompt from 'lib/components/core/dialogs/Prompt'; +import LiveFeedbackHistoryIndex from 'course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory'; + +const translations = defineMessages({ + tableTitle: { + id: 'course.statistics.StatisticsIndex.codaveri.tableTitle', + defaultMessage: 'Recent Live Feedback (last 7 days)', + }, + studentName: { + id: 'course.statistics.StatisticsIndex.codaveri.name', + defaultMessage: 'Name', + }, + messageCount: { + id: 'course.statistics.StatisticsIndex.codaveri.messageCount', + defaultMessage: '# Messages', + }, + lastMessage: { + id: 'course.statistics.StatisticsIndex.codaveri.lastMessage', + defaultMessage: 'Last Message', + }, + questionNumber: { + id: 'course.statistics.StatisticsIndex.codaveri.questionNumber', + defaultMessage: 'Question', + }, + assessmentTitle: { + id: 'course.statistics.StatisticsIndex.codaveri.assessmentTitle', + defaultMessage: 'Assessment', + }, + createdAt: { + id: 'course.statistics.StatisticsIndex.codaveri.createdAt', + defaultMessage: 'Last Message At', + }, + searchBar: { + id: 'course.statistics.StatisticsIndex.codaveri.searchBar', + defaultMessage: 'Search by Student Name, Question, or Assessment', + }, +}); + +const CodaveriStatisticsTable: FC<{ + liveFeedbacks: CodaveriLiveFeedbackActivity[]; +}> = ({ liveFeedbacks }) => { + const { t } = useTranslation(); + const { courseId } = useParams(); + const [openLiveFeedbackHistory, setOpenLiveFeedbackHistory] = useState(false); + const [courseLevelLiveFeedbackInfo, setCourseLevelLiveFeedbackInfo] = + useState({ + courseUserId: 0, + questionId: 0, + questionNumber: 0, + assessmentId: 0, + }); + + const columns: ColumnTemplate[] = [ + { + of: 'assessmentTitle', + title: t(translations.assessmentTitle), + sortable: true, + searchable: true, + cell: (feedback) => ( + + {feedback.assessmentTitle} + + ), + }, + { + of: 'questionNumber', + title: t(translations.questionNumber), + sortable: true, + searchable: true, + cell: (feedback) => ( + + Question {feedback.questionNumber} + {feedback.questionTitle ? `: ${feedback.questionTitle}` : ''} + + ), + }, + { + of: 'name', + title: t(translations.studentName), + sortable: true, + searchable: true, + cell: (feedback) => ( + + {feedback.name} + + ), + }, + { + of: 'messageCount', + title: t(translations.messageCount), + sortable: true, + searchable: true, + cell: (feedback) => ( +
+ { + e.preventDefault(); + setOpenLiveFeedbackHistory(true); + setCourseLevelLiveFeedbackInfo({ + courseUserId: feedback.userId, + questionId: feedback.questionId, + questionNumber: feedback.questionNumber, + assessmentId: feedback.assessmentId, + }); + }} + > + {feedback.messageCount} + +
+ ), + }, + { + of: 'createdAt', + title: t(translations.createdAt), + sortable: true, + cell: (feedback) => formatMiniDateTime(feedback.createdAt), + }, + { + of: 'lastMessage', + title: t(translations.lastMessage), + sortable: true, + searchable: true, + cell: (feedback) => ( + +
+ {feedback.lastMessage} +
+
+ ), + }, + ]; + + return ( + <> + + {t(translations.tableTitle)} + +
+ `codaveri_feedback_${feedback.id}` + } + getRowEqualityData={(feedback): CodaveriLiveFeedbackActivity => + feedback + } + getRowId={(feedback): string => feedback.id.toString()} + indexing={{ indices: true }} + pagination={{ + rowsPerPage: [DEFAULT_TABLE_ROWS_PER_PAGE], + showAllRows: true, + }} + search={{ + searchPlaceholder: t(translations.searchBar), + searchProps: { + shouldInclude: (feedback, filterValue?: string): boolean => { + if (!filterValue) return true; + + return ( + feedback.name + .toLowerCase() + .trim() + .includes(filterValue.toLowerCase().trim()) || + feedback.questionNumber + .toString() + .trim() + .includes(filterValue.toLowerCase().trim()) || + feedback.assessmentTitle + .toLowerCase() + .trim() + .includes(filterValue.toLowerCase().trim()) + ); + }, + }, + }} + /> + setOpenLiveFeedbackHistory(false)} + open={openLiveFeedbackHistory} + title={t( + assessmentStatisticsTranslations.liveFeedbackHistoryPromptTitle, + )} + > + + + + ); +}; + +export default CodaveriStatisticsTable; diff --git a/client/app/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index 9601c36192a..9c6cc7be3da 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -89,7 +89,14 @@ export interface AssessmentLiveFeedbackStatistics { groups: { name: string }[]; workflowState?: WorkflowState; submissionId?: number; - liveFeedbackCount?: number[]; // Will already be ordered by question - totalFeedbackCount?: number; + liveFeedbackData: AssessmentLiveFeedbackData[]; // Will already be ordered by question questionIds: number[]; + totalMetricCount?: number; +} + +export interface AssessmentLiveFeedbackData { + grade: number; + grade_diff: number; + prompt_count: number; + prompt_length: number; } diff --git a/spec/controllers/course/statistics/assessment_controller_spec.rb b/spec/controllers/course/statistics/assessment_controller_spec.rb index f4f7b1a4dbf..76e1d088fd3 100644 --- a/spec/controllers/course/statistics/assessment_controller_spec.rb +++ b/spec/controllers/course/statistics/assessment_controller_spec.rb @@ -214,15 +214,15 @@ expect(first_result).to have_key('workflowState') expect(first_result).to have_key('submissionId') expect(first_result).to have_key('groups') - expect(first_result).to have_key('liveFeedbackCount') + expect(first_result).to have_key('liveFeedbackData') expect(first_result).to have_key('questionIds') # Ensure that the feedback count is correct for the specific questions question_index = first_result['questionIds'].index(question1.id) if first_result['courseUser']['id'] == course_student.id - expect(first_result['liveFeedbackCount'][question_index]).to eq(3) + expect(first_result['liveFeedbackData'][question_index]['prompt_count']).to eq(3) else - expect(first_result['liveFeedbackCount'][question_index]).to eq(0) + expect(first_result['liveFeedbackData'][question_index]['prompt_count']).to eq(0) end end end @@ -247,15 +247,15 @@ expect(first_result).to have_key('workflowState') expect(first_result).to have_key('submissionId') expect(first_result).to have_key('groups') - expect(first_result).to have_key('liveFeedbackCount') + expect(first_result).to have_key('liveFeedbackData') expect(first_result).to have_key('questionIds') # Ensure that the feedback count is correct for the specific questions question_index = first_result['questionIds'].index(question1.id) if first_result['courseUser']['id'] == course_student.id - expect(first_result['liveFeedbackCount'][question_index]).to eq(3) + expect(first_result['liveFeedbackData'][question_index]['prompt_count']).to eq(3) else - expect(first_result['liveFeedbackCount'][question_index]).to eq(0) + expect(first_result['liveFeedbackData'][question_index]['prompt_count']).to eq(0) end end end From f8e2ab04e60bcda225ffe19413fb03558ebd5707 Mon Sep 17 00:00:00 2001 From: craigtonlian Date: Tue, 10 Jun 2025 21:08:25 +0800 Subject: [PATCH 2/3] fix(LiveFeedbackMetricSelector): update label and add tooltip --- .../statistics/assessments_controller.rb | 10 +- .../codaveri_activities.json.jbuilder | 22 -- .../api/course/Statistics/CourseStatistics.ts | 1 - .../LiveFeedbackStatisticsTable.tsx | 22 +- .../LiveFeedbackMetricsSelector.tsx | 78 ++++-- .../AssessmentStatistics/translations.ts | 16 +- .../codaveri/CodaveriStatistics.tsx | 17 -- .../codaveri/CodaveriStatisticsTable.tsx | 237 ------------------ .../course/statistics/assessmentStatistics.ts | 4 +- .../statistics/assessment_controller_spec.rb | 8 +- 10 files changed, 91 insertions(+), 324 deletions(-) delete mode 100644 app/views/course/statistics/aggregate/codaveri_activities.json.jbuilder delete mode 100644 client/app/bundles/course/statistics/pages/StatisticsIndex/codaveri/CodaveriStatistics.tsx delete mode 100644 client/app/bundles/course/statistics/pages/StatisticsIndex/codaveri/CodaveriStatisticsTable.tsx diff --git a/app/controllers/course/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index 3e758a145b2..b10400699d5 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -151,15 +151,15 @@ def build_live_feedback_data(submission, answer_hash, message_hash, prompt_hash) submission_question_id = @submission_question_id_hash[[submission&.id, question_id]] key = [submission&.creator_id, submission_question_id] - prompt_data = prompt_hash[key] || { prompt_count: 0, prompt_length: 0 } + prompt_data = prompt_hash[key] || { messages_sent: 0, word_count: 0 } messages = message_hash[key] || [] answer = answer_hash[[submission&.id, question_id]] { grade: answer, grade_diff: calculate_grade_diff(submission, question_id, messages), - prompt_length: prompt_data[:prompt_length], - prompt_count: prompt_data[:prompt_count] + word_count: prompt_data[:word_count], + messages_sent: prompt_data[:messages_sent] } end end @@ -167,8 +167,8 @@ def build_live_feedback_data(submission, answer_hash, message_hash, prompt_hash) def calculate_prompt_hash(message_hash) message_hash.transform_values do |messages| { - prompt_count: messages.size, - prompt_length: messages.sum { |m| m.content.split(/\s+/).size } + messages_sent: messages.size, + word_count: messages.sum { |m| m.content.split(/\s+/).size } } end end diff --git a/app/views/course/statistics/aggregate/codaveri_activities.json.jbuilder b/app/views/course/statistics/aggregate/codaveri_activities.json.jbuilder deleted file mode 100644 index a209ba9b674..00000000000 --- a/app/views/course/statistics/aggregate/codaveri_activities.json.jbuilder +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true -json.courseId current_course.id -json.liveFeedbacks @live_feedbacks do |feedback| - course_user = @course_user_hash[feedback.submission_creator_id] - - json.id feedback.id - json.userId course_user&.id - json.submissionId feedback.submission_id - json.assessmentId feedback.assessment_id - json.questionId feedback.question_id - - json.name course_user&.name - json.nameLink course_user_path(current_course, course_user) - - json.lastMessage feedback.content - json.messageCount feedback.message_count - json.questionNumber @assessment_question_hash[[feedback.assessment_id, feedback.question_id]][:question_number] - json.questionTitle @assessment_question_hash[[feedback.assessment_id, feedback.question_id]][:question_title] - json.assessmentTitle @assessment_question_hash[[feedback.assessment_id, feedback.question_id]][:assessment_title] - - json.createdAt feedback.created_at&.iso8601 -end diff --git a/client/app/api/course/Statistics/CourseStatistics.ts b/client/app/api/course/Statistics/CourseStatistics.ts index 4ec9d55cb21..ee79a79721e 100644 --- a/client/app/api/course/Statistics/CourseStatistics.ts +++ b/client/app/api/course/Statistics/CourseStatistics.ts @@ -3,7 +3,6 @@ import { JobSubmitted } from 'types/jobs'; import { APIResponse } from 'api/types'; import { AssessmentsStatistics, - CodaveriStatistics, CoursePerformanceStatistics, CourseProgressionStatistics, GetHelpStatistics, diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatisticsTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatisticsTable.tsx index 549766dbc9a..97abcc99aee 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatisticsTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatisticsTable.tsx @@ -35,15 +35,15 @@ const METRIC_CONFIG = { legendLowerLabel: 'legendLowerLabelGradeDiff', legendUpperLabel: 'legendUpperLabelGradeDiff', }, - prompt_count: { + messages_sent: { showTotal: true, - legendLowerLabel: 'legendLowerLabelPromptCount', - legendUpperLabel: 'legendUpperLabelPromptCount', + legendLowerLabel: 'legendLowerLabelMessagesSent', + legendUpperLabel: 'legendUpperLabelMessagesSent', }, - prompt_length: { + word_count: { showTotal: true, - legendLowerLabel: 'legendLowerLabelPromptLength', - legendUpperLabel: 'legendUpperLabelPromptLength', + legendLowerLabel: 'legendLowerLabelWordCount', + legendUpperLabel: 'legendUpperLabelWordCount', }, } as const; @@ -73,8 +73,8 @@ const LiveFeedbackStatisticsTable: FC = (props) => { questionNumber: 0, }); const [selectedMetric, setSelectedMetric] = useState({ - value: 'prompt_count', - label: 'Prompt Count', + value: 'messages_sent', + label: 'Messages Sent', }); useEffect(() => { @@ -170,10 +170,10 @@ const LiveFeedbackStatisticsTable: FC = (props) => { Grade Difference: {liveFeedbackData.grade_diff ?? '-'} - Prompt Count: {liveFeedbackData.prompt_count ?? '-'} + Messages Sent: {liveFeedbackData.messages_sent ?? '-'} - Prompt Length: {liveFeedbackData.prompt_length ?? '-'} + Word Count: {liveFeedbackData.word_count ?? '-'} ); @@ -213,7 +213,7 @@ const LiveFeedbackStatisticsTable: FC = (props) => { const tooltipContent = renderTooltipContent(liveFeedbackData); // If there is no LiveFeedbackHistory, we do not show the clickable cell - if (liveFeedbackData.prompt_count === 0) { + if (liveFeedbackData.messages_sent === 0) { return (
diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/components/LiveFeedbackMetricsSelector.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/components/LiveFeedbackMetricsSelector.tsx index fe3713129b1..4b14746518c 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/components/LiveFeedbackMetricsSelector.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/components/LiveFeedbackMetricsSelector.tsx @@ -1,5 +1,13 @@ import { FC } from 'react'; -import { Autocomplete, Box, TextField } from '@mui/material'; +import { + Autocomplete, + Box, + TextField, + Tooltip, + Typography, +} from '@mui/material'; + +import InfoLabel from 'lib/components/core/InfoLabel'; interface MetricOption { value: string; @@ -14,30 +22,66 @@ interface Props { const metricOptions: MetricOption[] = [ { value: 'grade', label: 'Grade' }, { value: 'grade_diff', label: 'Grade Difference' }, - { value: 'prompt_count', label: 'Prompt Count' }, - { value: 'prompt_length', label: 'Prompt Length' }, + { value: 'messages_sent', label: 'Messages Sent' }, + { value: 'word_count', label: 'Word Count' }, ]; +const metricDescriptions: Record = { + grade: 'The final grade assigned to the student.', + grade_diff: ( + <> + The grade difference between the{' '} + last answer before the first message and the{' '} + first answer after the last message. + + ), + messages_sent: 'The number of messages sent during the session.', + word_count: "Total word count from the user's messages.", +}; + const LiveFeedbackMetricSelector: FC = ({ selectedMetric, setSelectedMetric, }) => { + const description = + metricDescriptions[selectedMetric?.value] || + 'Select a metric to see its description.'; // Just in case no metric is selected + return ( - option.label} - isOptionEqualToValue={(option, value) => option.value === value.value} - onChange={(_, value) => { - if (value) setSelectedMetric(value); - }} - options={metricOptions} - renderInput={(params) => ( - - )} - value={selectedMetric} - /> + + + option.label} + isOptionEqualToValue={(option, value) => + option.value === value.value + } + onChange={(_, value) => { + if (value) setSelectedMetric(value); + }} + options={metricOptions} + renderInput={(params) => ( + + )} + value={selectedMetric} + /> + + {description}} + > +
+ +
+
+
); }; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/translations.ts b/client/app/bundles/course/assessment/pages/AssessmentStatistics/translations.ts index 42709206043..3cf65386ed6 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/translations.ts +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/translations.ts @@ -49,20 +49,20 @@ const translations = defineMessages({ id: 'course.assessment.statistics.legendHigherLabelGradeDiff', defaultMessage: 'Higher Grade Difference', }, - legendLowerLabelPromptCount: { - id: 'course.assessment.statistics.legendLowerLabelPromptCount', + legendLowerLabelMessagesSent: { + id: 'course.assessment.statistics.legendLowerLabelMessagesSent', defaultMessage: 'Lower Usage', }, - legendUpperLabelPromptCount: { - id: 'course.assessment.statistics.legendUpperLabelPromptCount', + legendUpperLabelMessagesSent: { + id: 'course.assessment.statistics.legendUpperLabelMessagesSent', defaultMessage: 'Higher Usage', }, - legendLowerLabelPromptLength: { - id: 'course.assessment.statistics.legendLowerLabelPromptLength', + legendLowerLabelWordCount: { + id: 'course.assessment.statistics.legendLowerLabelWordCount', defaultMessage: 'Lower Word Count', }, - legendUpperLabelPromptLength: { - id: 'course.assessment.statistics.legendUpperLabelPromptLength', + legendUpperLabelWordCount: { + id: 'course.assessment.statistics.legendUpperLabelWordCount', defaultMessage: 'Higher Word Count', }, liveFeedbackFilename: { diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/codaveri/CodaveriStatistics.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/codaveri/CodaveriStatistics.tsx deleted file mode 100644 index 7b81886562a..00000000000 --- a/client/app/bundles/course/statistics/pages/StatisticsIndex/codaveri/CodaveriStatistics.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { FC } from 'react'; - -import { fetchCodaveriStatistics } from 'course/statistics/operations'; -import LoadingIndicator from 'lib/components/core/LoadingIndicator'; -import Preload from 'lib/components/wrappers/Preload'; - -import CodaveriStatisticsTable from './CodaveriStatisticsTable'; - -const CodaveriStatistics: FC = () => { - return ( - } while={fetchCodaveriStatistics}> - {(data) => } - - ); -}; - -export default CodaveriStatistics; diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/codaveri/CodaveriStatisticsTable.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/codaveri/CodaveriStatisticsTable.tsx deleted file mode 100644 index 6471267988f..00000000000 --- a/client/app/bundles/course/statistics/pages/StatisticsIndex/codaveri/CodaveriStatisticsTable.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import { FC, useState } from 'react'; -import { defineMessages } from 'react-intl'; -import { useParams } from 'react-router-dom'; -import { Tooltip, Typography } from '@mui/material'; - -import { CodaveriLiveFeedbackActivity } from 'course/statistics/types'; -import Link from 'lib/components/core/Link'; -import { ColumnTemplate } from 'lib/components/table'; -import Table from 'lib/components/table/Table'; -import { - DEFAULT_TABLE_ROWS_PER_PAGE, - NUM_CELL_CLASS_NAME, -} from 'lib/constants/sharedConstants'; -import { - getEditSubmissionQuestionURL, - getEditSubmissionURL, -} from 'lib/helpers/url-builders'; -import assessmentStatisticsTranslations from '../../../../assessment/pages/AssessmentStatistics/translations'; -import useTranslation from 'lib/hooks/useTranslation'; -import { formatMiniDateTime } from 'lib/moment'; -import Prompt from 'lib/components/core/dialogs/Prompt'; -import LiveFeedbackHistoryIndex from 'course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory'; - -const translations = defineMessages({ - tableTitle: { - id: 'course.statistics.StatisticsIndex.codaveri.tableTitle', - defaultMessage: 'Recent Live Feedback (last 7 days)', - }, - studentName: { - id: 'course.statistics.StatisticsIndex.codaveri.name', - defaultMessage: 'Name', - }, - messageCount: { - id: 'course.statistics.StatisticsIndex.codaveri.messageCount', - defaultMessage: '# Messages', - }, - lastMessage: { - id: 'course.statistics.StatisticsIndex.codaveri.lastMessage', - defaultMessage: 'Last Message', - }, - questionNumber: { - id: 'course.statistics.StatisticsIndex.codaveri.questionNumber', - defaultMessage: 'Question', - }, - assessmentTitle: { - id: 'course.statistics.StatisticsIndex.codaveri.assessmentTitle', - defaultMessage: 'Assessment', - }, - createdAt: { - id: 'course.statistics.StatisticsIndex.codaveri.createdAt', - defaultMessage: 'Last Message At', - }, - searchBar: { - id: 'course.statistics.StatisticsIndex.codaveri.searchBar', - defaultMessage: 'Search by Student Name, Question, or Assessment', - }, -}); - -const CodaveriStatisticsTable: FC<{ - liveFeedbacks: CodaveriLiveFeedbackActivity[]; -}> = ({ liveFeedbacks }) => { - const { t } = useTranslation(); - const { courseId } = useParams(); - const [openLiveFeedbackHistory, setOpenLiveFeedbackHistory] = useState(false); - const [courseLevelLiveFeedbackInfo, setCourseLevelLiveFeedbackInfo] = - useState({ - courseUserId: 0, - questionId: 0, - questionNumber: 0, - assessmentId: 0, - }); - - const columns: ColumnTemplate[] = [ - { - of: 'assessmentTitle', - title: t(translations.assessmentTitle), - sortable: true, - searchable: true, - cell: (feedback) => ( - - {feedback.assessmentTitle} - - ), - }, - { - of: 'questionNumber', - title: t(translations.questionNumber), - sortable: true, - searchable: true, - cell: (feedback) => ( - - Question {feedback.questionNumber} - {feedback.questionTitle ? `: ${feedback.questionTitle}` : ''} - - ), - }, - { - of: 'name', - title: t(translations.studentName), - sortable: true, - searchable: true, - cell: (feedback) => ( - - {feedback.name} - - ), - }, - { - of: 'messageCount', - title: t(translations.messageCount), - sortable: true, - searchable: true, - cell: (feedback) => ( -
- { - e.preventDefault(); - setOpenLiveFeedbackHistory(true); - setCourseLevelLiveFeedbackInfo({ - courseUserId: feedback.userId, - questionId: feedback.questionId, - questionNumber: feedback.questionNumber, - assessmentId: feedback.assessmentId, - }); - }} - > - {feedback.messageCount} - -
- ), - }, - { - of: 'createdAt', - title: t(translations.createdAt), - sortable: true, - cell: (feedback) => formatMiniDateTime(feedback.createdAt), - }, - { - of: 'lastMessage', - title: t(translations.lastMessage), - sortable: true, - searchable: true, - cell: (feedback) => ( - -
- {feedback.lastMessage} -
-
- ), - }, - ]; - - return ( - <> - - {t(translations.tableTitle)} - -
- `codaveri_feedback_${feedback.id}` - } - getRowEqualityData={(feedback): CodaveriLiveFeedbackActivity => - feedback - } - getRowId={(feedback): string => feedback.id.toString()} - indexing={{ indices: true }} - pagination={{ - rowsPerPage: [DEFAULT_TABLE_ROWS_PER_PAGE], - showAllRows: true, - }} - search={{ - searchPlaceholder: t(translations.searchBar), - searchProps: { - shouldInclude: (feedback, filterValue?: string): boolean => { - if (!filterValue) return true; - - return ( - feedback.name - .toLowerCase() - .trim() - .includes(filterValue.toLowerCase().trim()) || - feedback.questionNumber - .toString() - .trim() - .includes(filterValue.toLowerCase().trim()) || - feedback.assessmentTitle - .toLowerCase() - .trim() - .includes(filterValue.toLowerCase().trim()) - ); - }, - }, - }} - /> - setOpenLiveFeedbackHistory(false)} - open={openLiveFeedbackHistory} - title={t( - assessmentStatisticsTranslations.liveFeedbackHistoryPromptTitle, - )} - > - - - - ); -}; - -export default CodaveriStatisticsTable; diff --git a/client/app/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index 9c6cc7be3da..54d00fa9d6f 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -97,6 +97,6 @@ export interface AssessmentLiveFeedbackStatistics { export interface AssessmentLiveFeedbackData { grade: number; grade_diff: number; - prompt_count: number; - prompt_length: number; + messages_sent: number; + word_count: number; } diff --git a/spec/controllers/course/statistics/assessment_controller_spec.rb b/spec/controllers/course/statistics/assessment_controller_spec.rb index 76e1d088fd3..d8e5c7d5553 100644 --- a/spec/controllers/course/statistics/assessment_controller_spec.rb +++ b/spec/controllers/course/statistics/assessment_controller_spec.rb @@ -220,9 +220,9 @@ # Ensure that the feedback count is correct for the specific questions question_index = first_result['questionIds'].index(question1.id) if first_result['courseUser']['id'] == course_student.id - expect(first_result['liveFeedbackData'][question_index]['prompt_count']).to eq(3) + expect(first_result['liveFeedbackData'][question_index]['messages_sent']).to eq(3) else - expect(first_result['liveFeedbackData'][question_index]['prompt_count']).to eq(0) + expect(first_result['liveFeedbackData'][question_index]['messages_sent']).to eq(0) end end end @@ -253,9 +253,9 @@ # Ensure that the feedback count is correct for the specific questions question_index = first_result['questionIds'].index(question1.id) if first_result['courseUser']['id'] == course_student.id - expect(first_result['liveFeedbackData'][question_index]['prompt_count']).to eq(3) + expect(first_result['liveFeedbackData'][question_index]['messages_sent']).to eq(3) else - expect(first_result['liveFeedbackData'][question_index]['prompt_count']).to eq(0) + expect(first_result['liveFeedbackData'][question_index]['messages_sent']).to eq(0) end end end From 48842b7298004ce3194ab69c79f4627aad5911d9 Mon Sep 17 00:00:00 2001 From: craigtonlian Date: Mon, 16 Jun 2025 15:39:44 +0800 Subject: [PATCH 3/3] fix(liveFeedbackStatistics): fix N+1 query in calculating grade difference --- .../statistics/assessments_controller.rb | 191 ++++++++++++------ .../AssessmentStatisticsPage.tsx | 2 +- .../AssessmentStatistics/translations.ts | 4 +- client/locales/en.json | 4 +- 4 files changed, 134 insertions(+), 67 deletions(-) diff --git a/app/controllers/course/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index b10400699d5..29d7af06882 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -108,56 +108,152 @@ def create_question_related_hash end def create_student_live_feedback_hash - answer_hash = fetch_current_answer_hash - message_hash = fetch_live_feedback_message_hash - prompt_hash = calculate_prompt_hash(message_hash) + message_grade_hash = fetch_message_grade_hash + prompt_hash = calculate_prompt_hash(message_grade_hash) submission_hash = @submissions.index_by(&:creator_id) + final_grade_hash = Course::Assessment::Answer.where( + submission_id: @submissions.pluck(:id), + current_answer: true + ).to_h { |answer| [[answer.submission_id, answer.question_id], answer&.grade&.to_f || 0] } + @student_live_feedback_hash = @all_students.to_h do |student| submission = submission_hash[student.user_id] - live_feedback_data = build_live_feedback_data(submission, answer_hash, message_hash, prompt_hash) + live_feedback_data = build_live_feedback_data(submission, final_grade_hash, message_grade_hash, prompt_hash) [student, [submission, live_feedback_data]] end end - def fetch_current_answer_hash - Course::Assessment::Answer.where( - submission_id: @submissions.pluck(:id), - current_answer: true - ).to_h { |answer| [[answer.submission_id, answer.question_id], answer&.grade&.to_f || 0] } + # Fetches all user Get Help messages grouped by [submission_creator_id, submission_question_id], + # along with `grade_before` and `grade_after` relative to the message timestamps. + # The returned structure looks like: + # { + # [4, 70] => { + # messages: [ + # { created_at: "2025-05-30T05:18:48.623076", content: "Explain the question" } + # ], + # grade_before: 0.0, + # grade_after: 75.0 + # }, + # [4, 72] => { + # messages: [ + # { created_at: "2025-05-30T05:19:38.71754", content: "Where am I wrong?" }, + # { created_at: "2025-05-30T05:19:47.08988", content: "How do I fix this?" }, + # { created_at: "2025-05-30T05:25:04.50411", content: "I am stuck" } + # ], + # grade_before: 10.0, + # grade_after: 10.0 + # }, + # ... + # } + def fetch_message_grade_hash + student_ids = @all_students.pluck(:user_id) + submission_question_ids = @submission_question_id_hash.values + + result = ActiveRecord::Base.connection.execute( + build_message_grade_sql(student_ids, submission_question_ids) + ) + + result.to_h do |row| + key = [row['submission_creator_id'], row['submission_question_id']] + [ + key, + messages: JSON.parse(row['messages_json']), + grade_before: row['grade_before']&.to_f || 0, + grade_after: row['grade_after']&.to_f || 0 + ] + end end - def fetch_live_feedback_message_hash - Course::Assessment::LiveFeedback::Message. - joins(:thread). - where(live_feedback_threads: { - submission_question_id: @submission_question_id_hash.values, - submission_creator_id: @all_students.pluck(:user_id) - }). - where.not(creator_id: User::SYSTEM_USER_ID). - select('live_feedback_threads.submission_creator_id', - 'live_feedback_threads.submission_question_id', - 'live_feedback_messages.created_at', - 'live_feedback_messages.content'). - order(:created_at). - group_by do |message| - [message.submission_creator_id, message.submission_question_id] - end + def build_message_grade_sql(student_ids, submission_question_ids) + <<-SQL + WITH feedback_messages AS ( + #{feedback_messages_cte(student_ids, submission_question_ids)} + ), + grades_before AS ( + #{grades_before_cte} + ), + grades_after AS ( + #{grades_after_cte} + ) + SELECT + f.submission_creator_id, + f.submission_question_id, + f.messages_json, + gb.grade_before, + ga.grade_after + FROM feedback_messages f + LEFT JOIN grades_before gb ON f.submission_creator_id = gb.submission_creator_id AND f.submission_question_id = gb.submission_question_id + LEFT JOIN grades_after ga ON f.submission_creator_id = ga.submission_creator_id AND f.submission_question_id = ga.submission_question_id + SQL + end + + def feedback_messages_cte(student_ids, submission_question_ids) + <<-SQL + SELECT + lft.submission_creator_id, + lft.submission_question_id, + json_agg(json_build_object( + 'created_at', m.created_at, + 'content', m.content + ) ORDER BY m.created_at) AS messages_json, + MIN(m.created_at) AS first_message_at, + MAX(m.created_at) AS last_message_at + FROM live_feedback_messages m + JOIN live_feedback_threads lft + ON lft.id = m.thread_id + WHERE m.creator_id != #{User::SYSTEM_USER_ID} + AND lft.submission_creator_id = ANY(ARRAY[#{student_ids.join(',')}]) + AND lft.submission_question_id = ANY(ARRAY[#{submission_question_ids.join(',')}]) + GROUP BY lft.submission_creator_id, lft.submission_question_id + SQL + end + + def grades_before_cte + <<-SQL + SELECT DISTINCT ON (a.submission_id, a.question_id) + a.grade AS grade_before, + lft.submission_creator_id, + lft.submission_question_id + FROM feedback_messages f + JOIN live_feedback_threads lft ON lft.submission_creator_id = f.submission_creator_id AND lft.submission_question_id = f.submission_question_id + JOIN course_assessment_submission_questions sq ON sq.id = lft.submission_question_id + JOIN course_assessment_answers a ON a.submission_id = sq.submission_id AND a.question_id = sq.question_id + WHERE a.created_at < f.first_message_at + ORDER BY a.submission_id, a.question_id, a.created_at DESC + SQL + end + + def grades_after_cte + <<-SQL + SELECT DISTINCT ON (a.submission_id, a.question_id) + a.grade AS grade_after, + lft.submission_creator_id, + lft.submission_question_id + FROM feedback_messages f + JOIN live_feedback_threads lft ON lft.submission_creator_id = f.submission_creator_id AND lft.submission_question_id = f.submission_question_id + JOIN course_assessment_submission_questions sq ON sq.id = lft.submission_question_id + JOIN course_assessment_answers a ON a.submission_id = sq.submission_id AND a.question_id = sq.question_id + WHERE a.created_at > f.last_message_at + ORDER BY a.submission_id, a.question_id, a.created_at ASC + SQL end - def build_live_feedback_data(submission, answer_hash, message_hash, prompt_hash) + def build_live_feedback_data(submission, final_grade_hash, message_grade_hash, prompt_hash) @ordered_questions.map do |question_id| submission_question_id = @submission_question_id_hash[[submission&.id, question_id]] key = [submission&.creator_id, submission_question_id] + message_grade_data = message_grade_hash[key] || {} + grade_before = message_grade_data[:grade_before] + grade_after = message_grade_data[:grade_after] + prompt_data = prompt_hash[key] || { messages_sent: 0, word_count: 0 } - messages = message_hash[key] || [] - answer = answer_hash[[submission&.id, question_id]] { - grade: answer, - grade_diff: calculate_grade_diff(submission, question_id, messages), + grade: final_grade_hash[[submission&.id, question_id]], + grade_diff: (grade_after && grade_before) ? (grade_after - grade_before).round(2) : 0, word_count: prompt_data[:word_count], messages_sent: prompt_data[:messages_sent] } @@ -165,44 +261,15 @@ def build_live_feedback_data(submission, answer_hash, message_hash, prompt_hash) end def calculate_prompt_hash(message_hash) - message_hash.transform_values do |messages| + message_hash.transform_values do |data| + messages = data[:messages] || [] { messages_sent: messages.size, - word_count: messages.sum { |m| m.content.split(/\s+/).size } + word_count: messages.sum { |m| m['content'].to_s.split(/\s+/).size } } end end - def calculate_grade_diff(submission, question_id, messages) - return 0 unless submission && messages.any? - - first_message_time = messages.first.created_at - last_message_time = messages.last.created_at - - answer_before = fetch_answer_before(submission, question_id, first_message_time) - answer_after = fetch_answer_after(submission, question_id, last_message_time) - - return 0 unless answer_after && answer_before - - (answer_after.grade.to_f - answer_before.grade.to_f).round(2) - end - - def fetch_answer_before(submission, question_id, timestamp) - Course::Assessment::Answer. - where(submission_id: submission.id, question_id: question_id). - where('created_at < ?', timestamp). - order(:created_at). - last - end - - def fetch_answer_after(submission, question_id, timestamp) - Course::Assessment::Answer. - where(submission_id: submission.id, question_id: question_id). - where('created_at > ?', timestamp). - order(:created_at). - first - end - def fetch_messages_for_question(submission_question_id) Course::Assessment::LiveFeedback::Message. joins(:thread). diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AssessmentStatisticsPage.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AssessmentStatisticsPage.tsx index 2fc661c2dec..5b0e260e431 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AssessmentStatisticsPage.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AssessmentStatisticsPage.tsx @@ -45,7 +45,7 @@ const translations = defineMessages({ }, liveFeedback: { id: 'course.assessment.statistics.liveFeedback', - defaultMessage: 'Live Feedback', + defaultMessage: 'Get Help', }, gradesPerQuestion: { id: 'course.assessment.statistics.gradesPerQuestion', diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/translations.ts b/client/app/bundles/course/assessment/pages/AssessmentStatistics/translations.ts index 3cf65386ed6..52b322d34e4 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/translations.ts +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/translations.ts @@ -67,11 +67,11 @@ const translations = defineMessages({ }, liveFeedbackFilename: { id: 'course.assessment.statistics.liveFeedback.filename', - defaultMessage: 'Question-level Live Feedback Statistics for {assessment}', + defaultMessage: 'Question-level Get Help Statistics for {assessment}', }, liveFeedbackHistoryPromptTitle: { id: 'course.assessment.statistics.liveFeedbackHistoryPromptTitle', - defaultMessage: 'Live Feedback History', + defaultMessage: 'Get Help History', }, marksFilename: { id: 'course.assessment.statistics.marks.filename', diff --git a/client/locales/en.json b/client/locales/en.json index e32456445f0..0b0773a0baa 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -2550,10 +2550,10 @@ "defaultMessage": "Lower Usage" }, "course.assessment.statistics.liveFeedback.filename": { - "defaultMessage": "Question-level Live Feedback Statistics for {assessment}" + "defaultMessage": "Question-level Get Help Statistics for {assessment}" }, "course.assessment.statistics.liveFeedbackHistoryPromptTitle": { - "defaultMessage": "Live Feedback History" + "defaultMessage": "Get Help History" }, "course.assessment.statistics.marks.filename": { "defaultMessage": "Question-level Marks Statistics for {assessment}"