From 6f32448010a1d71c7fc4e368617a6343d5220efc Mon Sep 17 00:00:00 2001 From: asreeku Date: Mon, 27 Apr 2026 12:47:47 -0400 Subject: [PATCH 01/41] Add FeedbackReport pipeline and ReportsController dispatch Ports feedback_response_map report from Expertiza (repo X) to the JSON API architecture of repo Y using a streaming reduce pipeline. - FeedbackResponseMap: adds feedback_response_report class method (deduplicates responses per map/round, buckets by round for varying-rubric assignments) - BaseReport: new abstract pipeline base (source -> grouper -> accumulate -> finalize via find_each streaming) - FeedbackReport: concrete pipeline subclass; uses Set for O(1) deduplication; fetches authors once in finalize - ReportsController: replaces send(type) dispatch with REPORT_CLASSES hash; renders JSON from report.run - ReportFormatterHelper: emptied (logic moved to services) Co-Authored-By: Claude Sonnet 4.6 --- app/controllers/reports_controller.rb | 38 +++++++++++ app/helpers/report_formatter_helper.rb | 7 ++ app/models/feedback_response_map.rb | 49 ++++++++++++++ app/services/reports/base_report.rb | 52 +++++++++++++++ app/services/reports/feedback_report.rb | 86 +++++++++++++++++++++++++ 5 files changed, 232 insertions(+) create mode 100644 app/controllers/reports_controller.rb create mode 100644 app/helpers/report_formatter_helper.rb create mode 100644 app/services/reports/base_report.rb create mode 100644 app/services/reports/feedback_report.rb diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb new file mode 100644 index 000000000..6c92a9c14 --- /dev/null +++ b/app/controllers/reports_controller.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class ReportsController < ApplicationController + REPORT_CLASSES = { + 'review_response_map' => Reports::ReviewReport, + 'feedback_response_map' => Reports::FeedbackReport, + 'teammate_review_response_map' => Reports::TeammateReviewReport, + 'bookmark_rating_response_map' => Reports::BookmarkRatingReport, + 'basic' => Reports::BasicReport + }.freeze + + # Only TAs, instructors, and admins may view reports. + def action_allowed? + current_user_has_ta_privileges? + end + + # GET/POST /reports/response_report?assignment_id=&type= + # Returns the requested report as JSON. + def response_report + assignment_id = params[:assignment_id] || params[:id] + type = params.dig(:report, :type) || params[:type] || 'basic' + + report_class = REPORT_CLASSES[type] + unless report_class + return render json: { + error: "Unknown report type: #{type}. Valid types: #{REPORT_CLASSES.keys.join(', ')}" + }, status: :unprocessable_entity + end + + assignment = Assignment.find(assignment_id) + data = report_class.new(assignment).run + render json: { type: type, assignment_id: assignment_id.to_i }.merge(data) + rescue ActiveRecord::RecordNotFound + render json: { error: 'Assignment not found' }, status: :not_found + rescue StandardError => e + render json: { error: e.message }, status: :internal_server_error + end +end diff --git a/app/helpers/report_formatter_helper.rb b/app/helpers/report_formatter_helper.rb new file mode 100644 index 000000000..6297c0fca --- /dev/null +++ b/app/helpers/report_formatter_helper.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Report logic has been moved to app/services/reports/. +# This module is retained as a namespace placeholder for any future +# view-layer formatting helpers. +module ReportFormatterHelper +end diff --git a/app/models/feedback_response_map.rb b/app/models/feedback_response_map.rb index 81f98fff1..7471d1886 100644 --- a/app/models/feedback_response_map.rb +++ b/app/models/feedback_response_map.rb @@ -20,4 +20,53 @@ def get_title def questionnaire_type 'AuthorFeedback' end + + # Returns authors (AssignmentParticipant) for the assignment and the IDs of the + # latest-per-map review responses that received author feedback. + # When the assignment uses varying rubrics by round, returns round-bucketed response ID arrays. + def self.feedback_response_report(assignment_id) + review_map_ids = ReviewResponseMap.where(reviewed_object_id: assignment_id).pluck(:id) + + teams = AssignmentTeam.includes(:users).where(parent_id: assignment_id) + authors = teams.flat_map do |team| + team.users.filter_map do |user| + AssignmentParticipant.find_by(parent_id: assignment_id, user_id: user.id) + end + end + + # Collect latest review responses per map (ordered newest-first) + temp_responses = Response.where(map_id: review_map_ids).order(created_at: :desc) + + assignment = Assignment.find(assignment_id) + seen_map_round_keys = [] + + if assignment.varying_rubrics_by_round? + round_one_ids = [] + round_two_ids = [] + round_three_ids = [] + + temp_responses.each do |response| + key = "#{response.map_id}-#{response.round}" + next if seen_map_round_keys.include?(key) + + seen_map_round_keys << key + round_one_ids << response.id if response.round == 1 + round_two_ids << response.id if response.round == 2 + round_three_ids << response.id if response.round == 3 + end + + [authors, round_one_ids, round_two_ids, round_three_ids] + else + all_review_response_ids = [] + + temp_responses.each do |response| + next if seen_map_round_keys.include?(response.map_id) + + seen_map_round_keys << response.map_id + all_review_response_ids << response.id + end + + [authors, all_review_response_ids] + end + end end \ No newline at end of file diff --git a/app/services/reports/base_report.rb b/app/services/reports/base_report.rb new file mode 100644 index 000000000..55461785f --- /dev/null +++ b/app/services/reports/base_report.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Reports + # Template for a streaming reduce-based report pipeline. + # + # Design rationale (addresses two anti-patterns from the naive approach): + # + # Anti-pattern 1 — "fetch_responses": loading all records into an unnamed + # ad-hoc array before processing wastes memory and forces the entire result + # set into Ruby-land. Instead, #run streams the source relation via + # find_each so memory usage scales with the number of *groups*, not rows. + # + # Anti-pattern 2 — "default metrics in base": encoding avg_score or any + # domain metric in the base class ties every report to one shape of math. + # This class contains *only* the pipeline scaffold; each subclass owns its + # accumulate/finalize logic entirely. + # + # Subclasses must implement (private): + # source → AR relation (consumed via find_each) + # grouper → lambda(row) → grouping key + # initial_state → empty accumulator value + # accumulate(state, key, row) → mutates state in place + # + # Subclasses may override (private): + # finalize(state) → transforms finished state into the output hash + # (default: returns state unchanged) + class BaseReport + def initialize(assignment) + @assignment = assignment + end + + def run + state = initial_state + source.find_each(batch_size: 500) do |row| + accumulate(state, grouper.call(row), row) + end + finalize(state) + end + + private + + def source = raise NotImplementedError, "#{self.class}#source" + def grouper = raise NotImplementedError, "#{self.class}#grouper" + def initial_state = raise NotImplementedError, "#{self.class}#initial_state" + + def accumulate(_state, _key, _row) + raise NotImplementedError, "#{self.class}#accumulate" + end + + def finalize(state) = state + end +end diff --git a/app/services/reports/feedback_report.rb b/app/services/reports/feedback_report.rb new file mode 100644 index 000000000..9da2e4709 --- /dev/null +++ b/app/services/reports/feedback_report.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Reports + # Author-feedback report: shows the latest review response IDs that received + # author feedback, bucketed by round for varying-rubric assignments. + # + # Pipeline: + # source — Responses on ReviewResponseMaps for this assignment, + # ordered newest-first so the first occurrence per (map, round) + # is the latest revision. + # grouper — [map_id, round]: deduplication key (one response per map per round) + # accumulate — skips duplicates; buckets response IDs by round + # finalize — fetches authors (one query), returns shaped hash + class FeedbackReport < BaseReport + def source + Response + .joins(:response_map) + .where(response_maps: { type: 'ReviewResponseMap', reviewed_object_id: @assignment.id }) + .order(created_at: :desc) + end + + def grouper = ->(r) { [r.map_id, r.round] } + + def initial_state + { seen: Set.new, round_1: [], round_2: [], round_3: [], all: [] } + end + + def accumulate(state, key, response) + return if state[:seen].include?(key) + + state[:seen].add(key) + + if @assignment.varying_rubrics_by_round? + case response.round + when 1 then state[:round_1] << response.id + when 2 then state[:round_2] << response.id + when 3 then state[:round_3] << response.id + end + else + state[:all] << response.id + end + end + + def finalize(state) + authors = fetch_authors + + if @assignment.varying_rubrics_by_round? + { + authors: authors.map { |a| format_participant(a) }, + review_response_ids: { + round_1: state[:round_1], + round_2: state[:round_2], + round_3: state[:round_3] + } + } + else + { + authors: authors.map { |a| format_participant(a) }, + review_response_ids: state[:all] + } + end + end + + private + + def fetch_authors + teams = AssignmentTeam.includes(:users).where(parent_id: @assignment.id) + teams.flat_map do |team| + team.users.filter_map do |user| + AssignmentParticipant.find_by(parent_id: @assignment.id, user_id: user.id) + end + end + end + + def format_participant(p) + return {} unless p + + { + id: p.id, + user_id: p.user_id, + name: p.user&.name, + full_name: p.user&.full_name + } + end + end +end From 533cab0330fe5850e6c9f277b5a4c8151aa1f4f9 Mon Sep 17 00:00:00 2001 From: asreeku Date: Mon, 27 Apr 2026 13:01:08 -0400 Subject: [PATCH 02/41] Remove unused feedback_response_report model method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FeedbackReport pipeline handles all querying, deduplication, and round bucketing directly — the model class method is no longer called. Co-Authored-By: Claude Sonnet 4.6 --- app/models/feedback_response_map.rb | 49 ----------------------------- 1 file changed, 49 deletions(-) diff --git a/app/models/feedback_response_map.rb b/app/models/feedback_response_map.rb index 7471d1886..81f98fff1 100644 --- a/app/models/feedback_response_map.rb +++ b/app/models/feedback_response_map.rb @@ -20,53 +20,4 @@ def get_title def questionnaire_type 'AuthorFeedback' end - - # Returns authors (AssignmentParticipant) for the assignment and the IDs of the - # latest-per-map review responses that received author feedback. - # When the assignment uses varying rubrics by round, returns round-bucketed response ID arrays. - def self.feedback_response_report(assignment_id) - review_map_ids = ReviewResponseMap.where(reviewed_object_id: assignment_id).pluck(:id) - - teams = AssignmentTeam.includes(:users).where(parent_id: assignment_id) - authors = teams.flat_map do |team| - team.users.filter_map do |user| - AssignmentParticipant.find_by(parent_id: assignment_id, user_id: user.id) - end - end - - # Collect latest review responses per map (ordered newest-first) - temp_responses = Response.where(map_id: review_map_ids).order(created_at: :desc) - - assignment = Assignment.find(assignment_id) - seen_map_round_keys = [] - - if assignment.varying_rubrics_by_round? - round_one_ids = [] - round_two_ids = [] - round_three_ids = [] - - temp_responses.each do |response| - key = "#{response.map_id}-#{response.round}" - next if seen_map_round_keys.include?(key) - - seen_map_round_keys << key - round_one_ids << response.id if response.round == 1 - round_two_ids << response.id if response.round == 2 - round_three_ids << response.id if response.round == 3 - end - - [authors, round_one_ids, round_two_ids, round_three_ids] - else - all_review_response_ids = [] - - temp_responses.each do |response| - next if seen_map_round_keys.include?(response.map_id) - - seen_map_round_keys << response.map_id - all_review_response_ids << response.id - end - - [authors, all_review_response_ids] - end - end end \ No newline at end of file From 8fd2d6636bd11960e4d0c6662b2c775a82f6d8f0 Mon Sep 17 00:00:00 2001 From: asreeku Date: Mon, 27 Apr 2026 13:01:35 -0400 Subject: [PATCH 03/41] Fix missing trailing newline in feedback_response_map.rb Co-Authored-By: Claude Sonnet 4.6 --- app/models/feedback_response_map.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/feedback_response_map.rb b/app/models/feedback_response_map.rb index 81f98fff1..92f676418 100644 --- a/app/models/feedback_response_map.rb +++ b/app/models/feedback_response_map.rb @@ -20,4 +20,4 @@ def get_title def questionnaire_type 'AuthorFeedback' end -end \ No newline at end of file +end From b9332a6894ef211a958b4ac32940bf174e7c7046 Mon Sep 17 00:00:00 2001 From: asreeku Date: Sat, 23 May 2026 21:41:40 -0400 Subject: [PATCH 04/41] Refactor AnswerTaggingReport to follow BaseReport pipeline pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DeploymentPipeline now inherits BaseReport, streaming TeamsUser grouped by user_id instead of nested team → user loops - Precompute taggable answer IDs and tags before the hot loop (zero DB queries in accumulate) - Remove varying_rubrics_by_round? branching — item_ids filter handles rubric scoping at the answer level - Extract model scopes: AnswerTag.for_deployment, TeamsUser.for_assignment, Answer.for_items_and_responses - Add pipeline benefit comment to BaseReport#run Co-Authored-By: Claude Sonnet 4.6 --- app/models/answer.rb | 2 + app/models/answer_tag.rb | 14 ++ app/models/reports/answer_tagging_report.rb | 193 ++++++++++++++++++ .../reports/base_report.rb | 26 ++- app/models/teams_user.rb | 2 + 5 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 app/models/answer_tag.rb create mode 100644 app/models/reports/answer_tagging_report.rb rename app/{services => models}/reports/base_report.rb (62%) diff --git a/app/models/answer.rb b/app/models/answer.rb index 3c3320afe..5d139a11e 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -3,4 +3,6 @@ class Answer < ApplicationRecord belongs_to :response belongs_to :item + + scope :for_items_and_responses, ->(item_ids, response_ids) { where(item_id: item_ids, response_id: response_ids) } end diff --git a/app/models/answer_tag.rb b/app/models/answer_tag.rb new file mode 100644 index 000000000..003649411 --- /dev/null +++ b/app/models/answer_tag.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AnswerTag < ApplicationRecord + belongs_to :answer + belongs_to :tag_prompt_deployment + belongs_to :user + + scope :for_deployment, ->(deployment_id) { where(tag_prompt_deployment_id: deployment_id) } + + validates :answer_id, presence: true + validates :tag_prompt_deployment_id, presence: true + validates :user_id, presence: true + validates :value, presence: true +end diff --git a/app/models/reports/answer_tagging_report.rb b/app/models/reports/answer_tagging_report.rb new file mode 100644 index 000000000..de2c6edbe --- /dev/null +++ b/app/models/reports/answer_tagging_report.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +module Reports + # Answer-tagging report: shows per-user tagging progress for each + # TagPromptDeployment on the assignment, plus a cross-deployment summary. + # + # Output shape: + # { + # questionnaire_tagging_report: { + # => { + # questionnaire_name:, prompt:, question_type:, + # answer_length_threshold:, + # user_stats: [{ user_id:, name:, full_name:, percentage:, + # cnt_tagged:, cnt_not_tagged:, cnt_taggable:, + # tag_update_intervals: }] + # } + # }, + # user_tagging_report: { + # "" => { user_id:, name:, full_name:, percentage:, + # cnt_tagged:, cnt_not_tagged:, cnt_taggable: } + # } + # } + class AnswerTaggingReport + def self.for_assignment(assignment) + new(assignment) + end + + def initialize(assignment) + @reportable = assignment + end + + def run + per_deployment = {} + # The coordinator runs one DeploymentPipeline per TagPromptDeployment, + # then passes the results to UserSummaryPipeline for cross-deployment totals. + # + # DeploymentPipeline (< BaseReport) — streams TeamsUser grouped by user_id: + # precomputes answer_ids_by_team (one SQL query) and tags_by_user (one + # SQL query), then accumulates per-user tagged / not-tagged / interval + # stats with no DB queries inside the hot loop. + TagPromptDeployment + .where(assignment_id: @reportable.id) + .includes(:tag_prompt, :questionnaire) + .each do |deployment| + per_deployment[deployment.id] = DeploymentPipeline.new(@reportable, deployment).run + end + # UserSummaryPipeline — aggregates DeploymentPipeline output across + # all deployments into a single per-user total. No additional DB queries. + user_summary = UserSummaryPipeline.new(per_deployment).run + { + questionnaire_tagging_report: per_deployment, + user_tagging_report: user_summary + } + end + + # ------------------------------------------------------------------------- + # Pipeline 1 — per-user tagging stats for one TagPromptDeployment. + # Streams TeamsUser (one row per assignment member) grouped by user_id. + # All DB-heavy work (answer IDs, tags) is precomputed before the hot loop. + # ------------------------------------------------------------------------- + class DeploymentPipeline < BaseReport + def initialize(reportable, deployment) + super(reportable) + @deployment = deployment + @tags_by_user = AnswerTag.for_deployment(deployment.id).group_by(&:user_id) + @taggable_answer_ids_by_team = fetch_taggable_answer_ids_associated_with_deployment + end + + def source + # For looping through users present in teams of the assignment. + TeamsUser.for_assignment(@reportable.id).includes(:user) + end + + def grouper = ->(team_membership) { team_membership.user_id } + + def initial_state = {} + + # Will be called for each row in the source stream (i.e., per each team user) + def accumulate(state, user_id, team_membership) + answer_ids = @taggable_answer_ids_by_team[team_membership.team_id] || [] + + # Assuming that a user is only a member of one team. Check with prof?? + cnt_taggable = answer_ids.size + return if cnt_taggable.zero? + + # This will take care of the last response submitted in each round case + user_tags = (@tags_by_user[user_id] || []).select { |tag| answer_ids.include?(tag.answer_id) } + cnt_tagged = user_tags.size + cnt_not_tagged = cnt_taggable - cnt_tagged + + tag_times_in_order = user_tags.map(&:updated_at).sort + # fetches 2 consecutive timestamps and computes the difference + intervals_between_tags = tag_times_in_order.each_cons(2).map do |earlier, later| + later - earlier + end + + state[user_id] = { + user_id: user_id, + name: team_membership.user.name, + full_name: team_membership.user.full_name, + percentage: format('%.1f', cnt_tagged.to_f / cnt_taggable * 100), + cnt_tagged: cnt_tagged, + cnt_not_tagged: cnt_not_tagged, + cnt_taggable: cnt_taggable, + tag_update_intervals: intervals_between_tags + } + end + + def finalize(state) + { + questionnaire_name: @deployment.questionnaire.name, + prompt: @deployment.tag_prompt.prompt, + question_type: @deployment.question_type, + answer_length_threshold: @deployment.answer_length_threshold, + user_stats: state.values + } + end + + private + + # One SQL query that returns taggable answer IDs keyed by team_id. + # No DB calls in the hot loop. + def fetch_taggable_answer_ids_associated_with_deployment + item_ids = fetch_item_ids_associated_with_deployment_questionnaire + return {} if item_ids.empty? + + response_ids_by_team = fetch_responses_received_by_teams_across_questionnaires_for_deployment_assignment + + # Transforms { team_id => [response_ids] } into { team_id => [answer_ids] } + response_ids_by_team.transform_values do |response_ids| + scope = Answer.for_items_and_responses(item_ids, response_ids) + scope = scope.where('LENGTH(comments) > ?', @deployment.answer_length_threshold) if @deployment.answer_length_threshold + scope.pluck(:id) + end + end + + def fetch_item_ids_associated_with_deployment_questionnaire + Item + .where(questionnaire_id: @deployment.questionnaire_id, + question_type: @deployment.question_type) + .pluck(:id) + end + + # Returns { team_id => [response_id, ...] } across all rubrics + # on this assignment. Scoping to the deployment's questionnaire happens + # via item_ids in fetch_taggable_answer_ids_by_team, not here. + def fetch_responses_received_by_teams_across_questionnaires_for_deployment_assignment + team_response_pairs = Response + .joins(:response_map) + .where( + response_maps: { reviewed_object_id: @reportable.id, type: 'ReviewResponseMap' }, + is_submitted: true + ) + .pluck('response_maps.reviewee_id', 'responses.id') + + response_ids_by_team = Hash.new { |by_team, team_id| by_team[team_id] = [] } + team_response_pairs.each do |team_id, response_id| + response_ids_by_team[team_id] << response_id + end + response_ids_by_team + end + end + + # ------------------------------------------------------------------------- + # Pipeline 2 — cross-deployment per-user summary. + # Consumes DeploymentPipeline output; no additional DB queries. + # ------------------------------------------------------------------------- + class UserSummaryPipeline + def initialize(per_deployment_result) + @per_deployment = per_deployment_result + end + + def run + summary = {} + @per_deployment.each_value do |deployment_data| + deployment_data[:user_stats].each do |stat| + key = stat[:name] + if summary.key?(key) + entry = summary[key] + entry[:cnt_tagged] += stat[:cnt_tagged] + entry[:cnt_not_tagged] += stat[:cnt_not_tagged] + entry[:cnt_taggable] += stat[:cnt_taggable] + entry[:percentage] = entry[:cnt_taggable].zero? ? '-' : format('%.1f', entry[:cnt_tagged].to_f / entry[:cnt_taggable] * 100) + else + summary[key] = stat.slice(:user_id, :name, :full_name, :cnt_tagged, :cnt_not_tagged, :cnt_taggable, :percentage) + end + end + end + summary + end + end + end +end diff --git a/app/services/reports/base_report.rb b/app/models/reports/base_report.rb similarity index 62% rename from app/services/reports/base_report.rb rename to app/models/reports/base_report.rb index 55461785f..5bf4a708b 100644 --- a/app/services/reports/base_report.rb +++ b/app/models/reports/base_report.rb @@ -25,10 +25,32 @@ module Reports # finalize(state) → transforms finished state into the output hash # (default: returns state unchanged) class BaseReport - def initialize(assignment) - @assignment = assignment + # Factory method for assignment-scoped reports. + def self.for_assignment(assignment) + new(assignment) end + # Factory method for course-scoped reports. + def self.for_course(course) + new(course) + end + + # @param reportable [Assignment, Course] the object the report is scoped to. + # Subclasses reference @reportable instead of a type-specific variable so + # the same pipeline works for any reportable entity. + def initialize(reportable) + @reportable = reportable + end + + # Runs the pipeline: stream → group → accumulate → finalize. + # + # Benefits of this structure over writing report code directly: + # 1. Memory safety — find_each streams in batches of 500 rather than + # loading the entire relation into Ruby. Every report gets this for free. + # 2. New reports are just data — subclasses define source/grouper/accumulate/ + # finalize; the pipeline wiring is not their concern. + # 3. Single place for cross-cutting concerns — logging, timing, or error + # handling can be added here once and applies to every report. def run state = initial_state source.find_each(batch_size: 500) do |row| diff --git a/app/models/teams_user.rb b/app/models/teams_user.rb index 8afd23cce..8e82c704f 100644 --- a/app/models/teams_user.rb +++ b/app/models/teams_user.rb @@ -4,6 +4,8 @@ class TeamsUser < ApplicationRecord belongs_to :user belongs_to :team + scope :for_assignment, ->(assignment_id) { joins(:team).where(teams: { parent_id: assignment_id }) } + def name(ip_address = nil) name = user.name(ip_address) end From 3e2529e3fb9033b177fe5155267bbda62e2787d5 Mon Sep 17 00:00:00 2001 From: asreeku Date: Sat, 23 May 2026 22:04:43 -0400 Subject: [PATCH 05/41] Extract model scopes and fix indentation in answer tagging report - Add Item.for_questionnaire_and_type scope - Add Response.submitted_review_responses_for scope; reuse in ReviewReport - Fix block indentation in AnswerTaggingReport#run - Fix Response chain indentation in fetch_responses_received_by_teams Co-Authored-By: Claude Sonnet 4.6 --- app/models/Item.rb | 2 + app/models/reports/answer_tagging_report.rb | 18 +- app/models/reports/review_report.rb | 173 ++++++++++++++++++++ app/models/response.rb | 11 +- 4 files changed, 189 insertions(+), 15 deletions(-) create mode 100644 app/models/reports/review_report.rb diff --git a/app/models/Item.rb b/app/models/Item.rb index 0b6535228..a908656ef 100644 --- a/app/models/Item.rb +++ b/app/models/Item.rb @@ -5,6 +5,8 @@ class Item < ApplicationRecord belongs_to :questionnaire # each item belongs to a specific questionnaire has_many :answers, dependent: :destroy, foreign_key: 'item_id' attr_accessor :choice_strategy + + scope :for_questionnaire_and_type, ->(questionnaire_id, question_type) { where(questionnaire_id: questionnaire_id, question_type: question_type) } validates :seq, presence: true, numericality: true # sequence must be numeric validates :txt, length: { minimum: 0, allow_nil: false, message: "can't be nil" } # text content must be provided diff --git a/app/models/reports/answer_tagging_report.rb b/app/models/reports/answer_tagging_report.rb index de2c6edbe..a6173c4cf 100644 --- a/app/models/reports/answer_tagging_report.rb +++ b/app/models/reports/answer_tagging_report.rb @@ -42,8 +42,8 @@ def run .where(assignment_id: @reportable.id) .includes(:tag_prompt, :questionnaire) .each do |deployment| - per_deployment[deployment.id] = DeploymentPipeline.new(@reportable, deployment).run - end + per_deployment[deployment.id] = DeploymentPipeline.new(@reportable, deployment).run + end # UserSummaryPipeline — aggregates DeploymentPipeline output across # all deployments into a single per-user total. No additional DB queries. user_summary = UserSummaryPipeline.new(per_deployment).run @@ -83,7 +83,6 @@ def accumulate(state, user_id, team_membership) cnt_taggable = answer_ids.size return if cnt_taggable.zero? - # This will take care of the last response submitted in each round case user_tags = (@tags_by_user[user_id] || []).select { |tag| answer_ids.include?(tag.answer_id) } cnt_tagged = user_tags.size cnt_not_tagged = cnt_taggable - cnt_tagged @@ -135,10 +134,7 @@ def fetch_taggable_answer_ids_associated_with_deployment end def fetch_item_ids_associated_with_deployment_questionnaire - Item - .where(questionnaire_id: @deployment.questionnaire_id, - question_type: @deployment.question_type) - .pluck(:id) + Item.for_questionnaire_and_type(@deployment.questionnaire_id, @deployment.question_type).pluck(:id) end # Returns { team_id => [response_id, ...] } across all rubrics @@ -146,12 +142,8 @@ def fetch_item_ids_associated_with_deployment_questionnaire # via item_ids in fetch_taggable_answer_ids_by_team, not here. def fetch_responses_received_by_teams_across_questionnaires_for_deployment_assignment team_response_pairs = Response - .joins(:response_map) - .where( - response_maps: { reviewed_object_id: @reportable.id, type: 'ReviewResponseMap' }, - is_submitted: true - ) - .pluck('response_maps.reviewee_id', 'responses.id') + .submitted_review_responses_for(@reportable.id) + .pluck('response_maps.reviewee_id', 'responses.id') response_ids_by_team = Hash.new { |by_team, team_id| by_team[team_id] = [] } team_response_pairs.each do |team_id, response_id| diff --git a/app/models/reports/review_report.rb b/app/models/reports/review_report.rb new file mode 100644 index 000000000..f8a367de3 --- /dev/null +++ b/app/models/reports/review_report.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +module Reports + # Peer-review report composed of three independent streaming pipelines: + # 1. ReviewersPipeline — distinct reviewers with user details + # 2. ScoresPipeline — per-reviewer × round × reviewee score pct + # 3. AvgRangesPipeline — per-team × round max / min / avg + # + # Each pipeline streams its own source relation via find_each, so no + # full result set is ever materialised in Ruby at once. + # max_question_score is precomputed once per (assignment, round) to avoid + # an N+1 query inside the hot accumulate loop. + class ReviewReport + # Factory method for assignment-scoped reports. + def self.for_assignment(assignment) + new(assignment) + end + + # Factory method for course-scoped reports. + def self.for_course(course) + new(course) + end + + # @param reportable [Assignment, Course] the object the report is scoped to. + def initialize(reportable) + @reportable = reportable + end + + def run + { + reviewers: ReviewersPipeline.new(@reportable).run, + review_scores: ScoresPipeline.new(@reportable).run, + avg_and_ranges: AvgRangesPipeline.new(@reportable).run + } + end + + # ----------------------------------------------------------------------- + # Pipeline 1 — distinct reviewers sorted by full name. + # Groups by reviewer_id; first occurrence per reviewer is kept. + # ----------------------------------------------------------------------- + class ReviewersPipeline < BaseReport + def source + ReviewResponseMap + .where(reviewed_object_id: @reportable.id) + .includes(reviewer: :user) + end + + # It pre-computes the grouping key once and passes it to accumulate so the subclass doesn't have to recompute it. + def grouper = ->(map) { map.reviewer_id } + def initial_state = {} + + def accumulate(state, reviewer_id, map) + return if state.key?(reviewer_id) + + r = map.reviewer + return unless r + + state[reviewer_id] = { + id: r.id, + user_id: r.user_id, + name: r.user&.name, + full_name: r.user&.full_name, + handle: r.handle + } + end + + def finalize(state) + state.values.sort_by { |r| r[:full_name].to_s.downcase } + end + end + + # ----------------------------------------------------------------------- + # Pipeline 2 — per-reviewer × round × reviewee score percentages. + # Streams submitted Responses with scores and items eagerly loaded. + # Output: { reviewer_id => { round => { reviewee_id => pct } } } + # ----------------------------------------------------------------------- + class ScoresPipeline < BaseReport + def initialize(reportable) + super + @max_q_score = precompute_max_q_scores + end + + def source + Response + .submitted_review_responses_for(@reportable.id) + .includes(:response_map, scores: :item) + end + def grouper = ->(r) { r.response_map.reviewer_id } + def initial_state = Hash.new { |h, k| h[k] = Hash.new { |h2, k2| h2[k2] = {} } } + + def accumulate(state, reviewer_id, response) + answered = response.scores.reject { |s| s.answer.nil? } + total_wt = answered.sum { |s| s.item.weight } + return if total_wt.zero? + + round = response.round || 1 + reviewee_id = response.response_map.reviewee_id + raw_score = answered.sum { |s| s.answer * s.item.weight } + max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1) + return unless max_score > 0 + + state[reviewer_id][round][reviewee_id] = ((raw_score.to_f / max_score) * 100).round(2) + end + + private + + def precompute_max_q_scores + AssignmentQuestionnaire + .joins(:questionnaire) + .where(assignment_id: @reportable.id) + .pluck(:used_in_round, 'questionnaires.max_question_score') + .to_h + end + end + + # ----------------------------------------------------------------------- + # Pipeline 3 — per-team × round aggregate (max / min / avg). + # Same response stream as Pipeline 2; groups by (reviewee_id, round). + # Output: { team_id => { round => { max:, min:, avg: } } } + # ----------------------------------------------------------------------- + class AvgRangesPipeline < BaseReport + def initialize(reportable) + super + @max_q_score = precompute_max_q_scores + end + + def source + Response + .submitted_review_responses_for(@reportable.id) + .includes(:response_map, scores: :item) + end + + def grouper = ->(r) { [r.response_map.reviewee_id, r.round || 1] } + def initial_state = Hash.new { |h, k| h[k] = [] } + + def accumulate(state, key, response) + answered = response.scores.reject { |s| s.answer.nil? } + total_wt = answered.sum { |s| s.item.weight } + return if total_wt.zero? + + round = key[1] + raw_score = answered.sum { |s| s.answer * s.item.weight } + max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1) + return unless max_score > 0 + + state[key] << ((raw_score.to_f / max_score) * 100) + end + + def finalize(state) + result = {} + state.each do |(team_id, round), scores| + result[team_id] ||= {} + result[team_id][round] = { + max: scores.max.round(2), + min: scores.min.round(2), + avg: (scores.sum / scores.size).round(2) + } + end + result + end + + private + + def precompute_max_q_scores + AssignmentQuestionnaire + .joins(:questionnaire) + .where(assignment_id: @reportable.id) + .pluck(:used_in_round, 'questionnaires.max_question_score') + .to_h + end + end + end +end diff --git a/app/models/response.rb b/app/models/response.rb index f99db770f..5ab9c00a3 100644 --- a/app/models/response.rb +++ b/app/models/response.rb @@ -5,6 +5,13 @@ class Response < ApplicationRecord include MetricHelper belongs_to :response_map, class_name: 'ResponseMap', foreign_key: 'map_id', inverse_of: false + + scope :submitted_review_responses_for, ->(assignment_id) { + joins(:response_map).where( + response_maps: { reviewed_object_id: assignment_id, type: 'ReviewResponseMap' }, + is_submitted: true + ) + } has_many :scores, class_name: 'Answer', foreign_key: 'response_id', dependent: :destroy, inverse_of: false alias map response_map @@ -26,7 +33,7 @@ def reportable_difference? existing_responses.each do |response| unless id == response.id # the current_response is also in existing_responses array count += 1 - total += response.aggregate_questionnaire_score.to_f / response.maximum_score + total += response.aggregate_questionnaire_score.to_f / response.maximum_score end end @@ -56,7 +63,7 @@ def aggregate_questionnaire_score sum = 0 scores.each do |s| # For quiz responses, the weights will be 1 or 0, depending on if correct - sum += s.answer * s.item.weight unless s.answer.nil? #|| !s.item.scorable? + sum += s.answer * s.item.weight unless s.answer.nil? #|| !s.item.scorable? end # puts "sum: #{sum}" sum From 49871f853fabb0c47b5eb8bd5f75932f152e70dd Mon Sep 17 00:00:00 2001 From: asreeku Date: Sun, 24 May 2026 04:36:09 -0400 Subject: [PATCH 06/41] Refactor ReviewReport pipelines for clarity and deduplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract ReviewResponsePipelineShared module with shared source and precompute_max_q_scores, included by ScoresPipeline and AvgRangesPipeline - Filter max question scores to ReviewQuestionnaire type only - Refactor AvgRangesPipeline to group by reviewee_id with round extracted in accumulate; simplify finalize with transform_values - Improve initial_state readability with named block params and comments - Remove unused for_course factory method - Rename terse lambda params (r → reviewer, response_map, response) Co-Authored-By: Claude Sonnet 4.6 --- app/models/reports/review_report.rb | 156 ++++++++++++++-------------- 1 file changed, 80 insertions(+), 76 deletions(-) diff --git a/app/models/reports/review_report.rb b/app/models/reports/review_report.rb index f8a367de3..9c86f6ce0 100644 --- a/app/models/reports/review_report.rb +++ b/app/models/reports/review_report.rb @@ -2,38 +2,51 @@ module Reports # Peer-review report composed of three independent streaming pipelines: - # 1. ReviewersPipeline — distinct reviewers with user details - # 2. ScoresPipeline — per-reviewer × round × reviewee score pct - # 3. AvgRangesPipeline — per-team × round max / min / avg + # 1 ReviewersPipeline — distinct reviewers with user details + # 2 ScoresPipeline — per-reviewer × round × reviewee score pct + # 3 AvgRangesPipeline — per-team × round max / min / avg # # Each pipeline streams its own source relation via find_each, so no # full result set is ever materialised in Ruby at once. # max_question_score is precomputed once per (assignment, round) to avoid # an N+1 query inside the hot accumulate loop. class ReviewReport - # Factory method for assignment-scoped reports. def self.for_assignment(assignment) new(assignment) end - # Factory method for course-scoped reports. - def self.for_course(course) - new(course) - end - - # @param reportable [Assignment, Course] the object the report is scoped to. def initialize(reportable) @reportable = reportable end def run { - reviewers: ReviewersPipeline.new(@reportable).run, - review_scores: ScoresPipeline.new(@reportable).run, + reviewers: ReviewersPipeline.new(@reportable).run, + review_scores: ScoresPipeline.new(@reportable).run, avg_and_ranges: AvgRangesPipeline.new(@reportable).run } end + # Shared source and max-score precomputation for ScoresPipeline and AvgRangesPipeline. + # Both pipelines stream the same submitted ReviewResponseMap responses and need + # the same max question score lookup — extracted here to avoid duplication. + module ReviewResponsePipelineShared + def source + Response + .submitted_review_responses_for(@reportable.id) + .includes(:response_map, scores: :item) + end + + def precompute_max_q_scores + # Check with prof if we need to use score_views?? + AssignmentQuestionnaire + .joins(:questionnaire) + .where(assignment_id: @reportable.id, questionnaires: { questionnaire_type: 'ReviewQuestionnaire' }) + .pluck(:used_in_round, 'questionnaires.max_question_score') + .to_h + end + end + # ----------------------------------------------------------------------- # Pipeline 1 — distinct reviewers sorted by full name. # Groups by reviewer_id; first occurrence per reviewer is kept. @@ -46,26 +59,27 @@ def source end # It pre-computes the grouping key once and passes it to accumulate so the subclass doesn't have to recompute it. - def grouper = ->(map) { map.reviewer_id } + def grouper = ->(response_map) { response_map.reviewer_id } + def initial_state = {} - def accumulate(state, reviewer_id, map) + def accumulate(state, reviewer_id, response_map) return if state.key?(reviewer_id) - r = map.reviewer + reviewer = response_map.reviewer return unless r state[reviewer_id] = { - id: r.id, - user_id: r.user_id, - name: r.user&.name, - full_name: r.user&.full_name, - handle: r.handle + id: reviewer.id, + user_id: reviewer.user_id, + name: reviewer.user&.name, + full_name: reviewer.user&.full_name, + handle: reviewer.handle } end def finalize(state) - state.values.sort_by { |r| r[:full_name].to_s.downcase } + state.values.sort_by { |reviewer| reviewer[:full_name].to_s.downcase } end end @@ -75,98 +89,88 @@ def finalize(state) # Output: { reviewer_id => { round => { reviewee_id => pct } } } # ----------------------------------------------------------------------- class ScoresPipeline < BaseReport + include ReviewResponsePipelineShared + def initialize(reportable) super @max_q_score = precompute_max_q_scores end - def source - Response - .submitted_review_responses_for(@reportable.id) - .includes(:response_map, scores: :item) + def grouper = ->(response) { response.response_map.reviewer_id } + + def initial_state + # Three-level nested hash: reviewer_id => round => reviewee_id => score_pct + # Each level auto-initializes when a missing key is accessed, + # so state[reviewer_id][round][reviewee_id] = pct never raises NoMethodError. + Hash.new do |by_reviewer, reviewer_id| + by_reviewer[reviewer_id] = Hash.new do |by_round, round| + by_round[round] = {} + end + end end - def grouper = ->(r) { r.response_map.reviewer_id } - def initial_state = Hash.new { |h, k| h[k] = Hash.new { |h2, k2| h2[k2] = {} } } def accumulate(state, reviewer_id, response) - answered = response.scores.reject { |s| s.answer.nil? } - total_wt = answered.sum { |s| s.item.weight } + answered = response.scores.reject { |s| s.answer.nil? } + total_wt = answered.sum { |s| s.item.weight } return if total_wt.zero? - round = response.round || 1 + round = response.round || 1 reviewee_id = response.response_map.reviewee_id - raw_score = answered.sum { |s| s.answer * s.item.weight } - max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1) - return unless max_score > 0 + raw_score = answered.sum { |s| s.answer * s.item.weight } + max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1) + return unless max_score.positive? state[reviewer_id][round][reviewee_id] = ((raw_score.to_f / max_score) * 100).round(2) end - - private - - def precompute_max_q_scores - AssignmentQuestionnaire - .joins(:questionnaire) - .where(assignment_id: @reportable.id) - .pluck(:used_in_round, 'questionnaires.max_question_score') - .to_h - end end # ----------------------------------------------------------------------- # Pipeline 3 — per-team × round aggregate (max / min / avg). - # Same response stream as Pipeline 2; groups by (reviewee_id, round). + # Same response stream as Pipeline 2; groups by reviewee_id. # Output: { team_id => { round => { max:, min:, avg: } } } # ----------------------------------------------------------------------- + # Check with prof how they are planning to use this information?? Is it for AVG score column? class AvgRangesPipeline < BaseReport + include ReviewResponsePipelineShared + def initialize(reportable) super @max_q_score = precompute_max_q_scores end - def source - Response - .submitted_review_responses_for(@reportable.id) - .includes(:response_map, scores: :item) - end + def grouper = ->(response) { response.response_map.reviewee_id } - def grouper = ->(r) { [r.response_map.reviewee_id, r.round || 1] } - def initial_state = Hash.new { |h, k| h[k] = [] } + def initial_state + # Two-level nested hash: reviewee_id => round => [score_pcts] + # Each level auto-initializes when a missing key is accessed. + Hash.new do |by_team, reviewee_id| + by_team[reviewee_id] = Hash.new { |by_round, round| by_round[round] = [] } + end + end - def accumulate(state, key, response) - answered = response.scores.reject { |s| s.answer.nil? } - total_wt = answered.sum { |s| s.item.weight } + def accumulate(state, reviewee_id, response) + answered = response.scores.reject { |s| s.answer.nil? } + total_wt = answered.sum { |s| s.item.weight } return if total_wt.zero? - round = key[1] + round = response.round || 1 raw_score = answered.sum { |s| s.answer * s.item.weight } max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1) - return unless max_score > 0 + return unless max_score.positive? - state[key] << ((raw_score.to_f / max_score) * 100) + state[reviewee_id][round] << ((raw_score.to_f / max_score) * 100) end def finalize(state) - result = {} - state.each do |(team_id, round), scores| - result[team_id] ||= {} - result[team_id][round] = { - max: scores.max.round(2), - min: scores.min.round(2), - avg: (scores.sum / scores.size).round(2) - } + state.transform_values do |rounds| + rounds.transform_values do |scores| + { + max: scores.max.round(2), + min: scores.min.round(2), + avg: (scores.sum / scores.size).round(2) + } + end end - result - end - - private - - def precompute_max_q_scores - AssignmentQuestionnaire - .joins(:questionnaire) - .where(assignment_id: @reportable.id) - .pluck(:used_in_round, 'questionnaires.max_question_score') - .to_h end end end From b8f3ebc67d7845d3ef43c1fa984f406dc291398a Mon Sep 17 00:00:00 2001 From: asreeku Date: Sun, 24 May 2026 04:44:44 -0400 Subject: [PATCH 07/41] Extract model scopes for ReviewReport pipeline queries - Add ReviewResponseMap.for_assignment scope - Add AssignmentQuestionnaire.for_assignment_and_type scope (generic, reusable) - Use scopes in ReviewersPipeline source and precompute_max_q_scores Co-Authored-By: Claude Sonnet 4.6 --- app/models/assignment_questionnaire.rb | 4 ++++ app/models/reports/review_report.rb | 7 ++----- app/models/review_response_map.rb | 10 ++++++++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/app/models/assignment_questionnaire.rb b/app/models/assignment_questionnaire.rb index 87a55883d..3efa4e9bc 100644 --- a/app/models/assignment_questionnaire.rb +++ b/app/models/assignment_questionnaire.rb @@ -3,4 +3,8 @@ class AssignmentQuestionnaire < ApplicationRecord belongs_to :assignment belongs_to :questionnaire + + scope :for_assignment_and_type, ->(assignment_id, questionnaire_type) { + joins(:questionnaire).where(assignment_id: assignment_id, questionnaires: { questionnaire_type: questionnaire_type }) + } end diff --git a/app/models/reports/review_report.rb b/app/models/reports/review_report.rb index 9c86f6ce0..b1302cb33 100644 --- a/app/models/reports/review_report.rb +++ b/app/models/reports/review_report.rb @@ -40,8 +40,7 @@ def source def precompute_max_q_scores # Check with prof if we need to use score_views?? AssignmentQuestionnaire - .joins(:questionnaire) - .where(assignment_id: @reportable.id, questionnaires: { questionnaire_type: 'ReviewQuestionnaire' }) + .for_assignment_and_type(@reportable.id, 'ReviewQuestionnaire') .pluck(:used_in_round, 'questionnaires.max_question_score') .to_h end @@ -53,9 +52,7 @@ def precompute_max_q_scores # ----------------------------------------------------------------------- class ReviewersPipeline < BaseReport def source - ReviewResponseMap - .where(reviewed_object_id: @reportable.id) - .includes(reviewer: :user) + ReviewResponseMap.for_assignment(@reportable.id).includes(reviewer: :user) end # It pre-computes the grouping key once and passes it to accumulate so the subclass doesn't have to recompute it. diff --git a/app/models/review_response_map.rb b/app/models/review_response_map.rb index ce38793dc..677dce9cf 100644 --- a/app/models/review_response_map.rb +++ b/app/models/review_response_map.rb @@ -3,6 +3,8 @@ class ReviewResponseMap < ResponseMap include ResponseMapSubclassTitles belongs_to :reviewee, class_name: 'Team', foreign_key: 'reviewee_id', inverse_of: false + scope :for_assignment, ->(assignment_id) { where(reviewed_object_id: assignment_id) } + # returns the assignment related to the response map def response_assignment return assignment @@ -20,4 +22,12 @@ def get_title def review_map_type 'ReviewResponseMap' end + + # Returns an array of distinct reviewers (AssignmentParticipant objects) for the given assignment. + # Reviewers are sorted by their user's full name. + def self.review_response_report(assignment_id) + distinct_reviewer_ids = where(reviewed_object_id: assignment_id).distinct.pluck(:reviewer_id) + reviewers = AssignmentParticipant.where(id: distinct_reviewer_ids, parent_id: assignment_id) + reviewers.sort_by { |r| r.user&.full_name.to_s.downcase } + end end From a118c02e9a77ac2a21e774997817fb0243eb8a3a Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Sun, 24 May 2026 04:53:03 -0400 Subject: [PATCH 08/41] Test commit to verify GitHub contribution tracking for aanand-1706 From 06e3dc98c2885785d93e874b58320d052bb144ed Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Sun, 24 May 2026 13:01:29 -0400 Subject: [PATCH 09/41] Refactor AnswerTaggingReport into two streaming pipelines Replace single DeploymentPipeline with TaggableAnswersPipeline and TaggingStatsPipeline. TaggableAnswersPipeline streams taggable Answer rows accumulating answer_ids and response_ids per user via a new Answer.taggable_for_assignment scope. TaggingStatsPipeline streams AnswerTag rows with response context; finalize filters by response_id membership instead of preloading all tags into memory. UserSummaryPipeline now keys by user_id to avoid collisions for users with identical names. Co-Authored-By: Claude Sonnet 4.6 --- app/models/answer.rb | 11 + app/models/reports/answer_tagging_report.rb | 224 ++++++++++++-------- 2 files changed, 148 insertions(+), 87 deletions(-) diff --git a/app/models/answer.rb b/app/models/answer.rb index 5d139a11e..a62268d94 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -5,4 +5,15 @@ class Answer < ApplicationRecord belongs_to :item scope :for_items_and_responses, ->(item_ids, response_ids) { where(item_id: item_ids, response_id: response_ids) } + + scope :taggable_for_assignment, ->(assignment_id, item_ids, type:, threshold: nil) { + scope = joins(response: :response_map) + .where( + item_id: item_ids, + responses: { is_submitted: true }, + response_maps: { reviewed_object_id: assignment_id, type: type } + ) + scope = scope.where('LENGTH(answers.comments) > ?', threshold) if threshold + scope + } end diff --git a/app/models/reports/answer_tagging_report.rb b/app/models/reports/answer_tagging_report.rb index a6173c4cf..eefbdc801 100644 --- a/app/models/reports/answer_tagging_report.rb +++ b/app/models/reports/answer_tagging_report.rb @@ -16,8 +16,8 @@ module Reports # } # }, # user_tagging_report: { - # "" => { user_id:, name:, full_name:, percentage:, - # cnt_tagged:, cnt_not_tagged:, cnt_taggable: } + # => { user_id:, name:, full_name:, percentage:, + # cnt_tagged:, cnt_not_tagged:, cnt_taggable: } # } # } class AnswerTaggingReport @@ -33,128 +33,178 @@ def run per_deployment = {} # The coordinator runs one DeploymentPipeline per TagPromptDeployment, # then passes the results to UserSummaryPipeline for cross-deployment totals. - # - # DeploymentPipeline (< BaseReport) — streams TeamsUser grouped by user_id: - # precomputes answer_ids_by_team (one SQL query) and tags_by_user (one - # SQL query), then accumulates per-user tagged / not-tagged / interval - # stats with no DB queries inside the hot loop. TagPromptDeployment .where(assignment_id: @reportable.id) .includes(:tag_prompt, :questionnaire) .each do |deployment| per_deployment[deployment.id] = DeploymentPipeline.new(@reportable, deployment).run end - # UserSummaryPipeline — aggregates DeploymentPipeline output across - # all deployments into a single per-user total. No additional DB queries. user_summary = UserSummaryPipeline.new(per_deployment).run { questionnaire_tagging_report: per_deployment, - user_tagging_report: user_summary + user_tagging_report: user_summary } end # ------------------------------------------------------------------------- - # Pipeline 1 — per-user tagging stats for one TagPromptDeployment. - # Streams TeamsUser (one row per assignment member) grouped by user_id. - # All DB-heavy work (answer IDs, tags) is precomputed before the hot loop. + # Coordinator — runs two streaming pipelines for one TagPromptDeployment + # and merges their results: + # + # TaggableAnswersPipeline — streams taggable Answer rows (joined with + # response_maps, filtered by item type and threshold). Returns per-user + # lists of taggable answer_ids and their response_ids. + # Output: { user_id => { answer_ids: [], response_ids: Set[] } } + # + # TaggingStatsPipeline — streams all AnswerTag rows for this deployment + # (joined with answers to get response_id). No item/threshold filtering + # in SQL — filtering happens in finalize by comparing response_ids. + # Output: { user_id => [{ answer_id:, response_id:, updated_at: }] } + # + # Precomputed once, shared across both pipelines: + # @item_ids — IDs of items in the deployment's questionnaire (tiny) + # @users_by_team — TeamsUser records grouped by team_id; also used in + # finalize to build per-user name info # ------------------------------------------------------------------------- - class DeploymentPipeline < BaseReport + class DeploymentPipeline def initialize(reportable, deployment) - super(reportable) - @deployment = deployment - @tags_by_user = AnswerTag.for_deployment(deployment.id).group_by(&:user_id) - @taggable_answer_ids_by_team = fetch_taggable_answer_ids_associated_with_deployment + @reportable = reportable + @deployment = deployment + @item_ids = Item.for_questionnaire_and_type(deployment.questionnaire_id, deployment.question_type).pluck(:id) + @users_by_team = TeamsUser.for_assignment(deployment.assignment_id).includes(:user).group_by(&:team_id) end - def source - # For looping through users present in teams of the assignment. - TeamsUser.for_assignment(@reportable.id).includes(:user) + def run + taggable_data = TaggableAnswersPipeline.new(@reportable, @deployment, @item_ids, @users_by_team).run + tagging_stats = TaggingStatsPipeline.new(@reportable, @deployment).run + finalize(taggable_data, tagging_stats) end - def grouper = ->(team_membership) { team_membership.user_id } - - def initial_state = {} - - # Will be called for each row in the source stream (i.e., per each team user) - def accumulate(state, user_id, team_membership) - answer_ids = @taggable_answer_ids_by_team[team_membership.team_id] || [] - - # Assuming that a user is only a member of one team. Check with prof?? - cnt_taggable = answer_ids.size - return if cnt_taggable.zero? - - user_tags = (@tags_by_user[user_id] || []).select { |tag| answer_ids.include?(tag.answer_id) } - cnt_tagged = user_tags.size - cnt_not_tagged = cnt_taggable - cnt_tagged + private - tag_times_in_order = user_tags.map(&:updated_at).sort - # fetches 2 consecutive timestamps and computes the difference - intervals_between_tags = tag_times_in_order.each_cons(2).map do |earlier, later| - later - earlier + def finalize(taggable_data, tagging_stats) + user_info = @users_by_team.values.flatten.each_with_object({}) do |teams_user, info| + info[teams_user.user_id] = { name: teams_user.user.name, full_name: teams_user.user.full_name } end - state[user_id] = { - user_id: user_id, - name: team_membership.user.name, - full_name: team_membership.user.full_name, - percentage: format('%.1f', cnt_tagged.to_f / cnt_taggable * 100), - cnt_tagged: cnt_tagged, - cnt_not_tagged: cnt_not_tagged, - cnt_taggable: cnt_taggable, - tag_update_intervals: intervals_between_tags - } - end + user_stats = user_info.map do |user_id, info| + taggable = taggable_data.fetch(user_id, {}) + cnt_taggable = (taggable[:answer_ids] || []).size + taggable_response_ids = taggable[:response_ids] || Set.new + + # Filter tags to only those belonging to taggable responses for this user. + # TODO: confirm with prof — if a reviewer submits multiple responses for the + # same round, only the latest submitted response should be counted as taggable. + # TaggableAnswersPipeline may need to deduplicate response_ids by keeping only + # the most recently submitted response per (reviewer, round). + matching_tags = tagging_stats.fetch(user_id, []).select { |tag| taggable_response_ids.include?(tag[:response_id]) } + cnt_tagged = matching_tags.size + cnt_not_tagged = cnt_taggable - cnt_tagged + + tag_times_in_order = matching_tags.map { |tag| tag[:updated_at] }.sort + intervals_between_tags = tag_times_in_order.each_cons(2).map { |earlier, later| later - earlier } + + { + user_id: user_id, + name: info[:name], + full_name: info[:full_name], + percentage: cnt_taggable.zero? ? '0.0' : format('%.1f', cnt_tagged.to_f / cnt_taggable * 100), + cnt_tagged: cnt_tagged, + cnt_not_tagged: cnt_not_tagged, + cnt_taggable: cnt_taggable, + tag_update_intervals: intervals_between_tags + } + end - def finalize(state) { - questionnaire_name: @deployment.questionnaire.name, - prompt: @deployment.tag_prompt.prompt, - question_type: @deployment.question_type, + questionnaire_name: @deployment.questionnaire.name, + prompt: @deployment.tag_prompt.prompt, + question_type: @deployment.question_type, answer_length_threshold: @deployment.answer_length_threshold, - user_stats: state.values + user_stats: user_stats } end - private + # ----------------------------------------------------------------------- + # Pipeline 1 — per-user taggable answer and response IDs. + # + # Streams taggable Answer rows (joined with responses and response_maps, + # filtered by item type and length threshold). Each row is one (answer, team) + # pair — answers.id is unique per row so find_each paginates correctly. + # For each row, adds the answer_id and response_id to all users of the team. + # + # Output: { user_id => { answer_ids: [], response_ids: Set[] } } + # ----------------------------------------------------------------------- + class TaggableAnswersPipeline < BaseReport + def initialize(reportable, deployment, item_ids, users_by_team) + super(reportable) + @deployment = deployment + @item_ids = item_ids + @users_by_team = users_by_team + end - # One SQL query that returns taggable answer IDs keyed by team_id. - # No DB calls in the hot loop. - def fetch_taggable_answer_ids_associated_with_deployment - item_ids = fetch_item_ids_associated_with_deployment_questionnaire - return {} if item_ids.empty? + def source + return Answer.none if @item_ids.empty? - response_ids_by_team = fetch_responses_received_by_teams_across_questionnaires_for_deployment_assignment + Answer + .taggable_for_assignment( + @deployment.assignment_id, @item_ids, + type: 'ReviewResponseMap', + threshold: @deployment.answer_length_threshold + ) + .select('answers.id, answers.response_id, response_maps.reviewee_id as team_id') + end + + def grouper = ->(answer) { answer.team_id } - # Transforms { team_id => [response_ids] } into { team_id => [answer_ids] } - response_ids_by_team.transform_values do |response_ids| - scope = Answer.for_items_and_responses(item_ids, response_ids) - scope = scope.where('LENGTH(comments) > ?', @deployment.answer_length_threshold) if @deployment.answer_length_threshold - scope.pluck(:id) + def initial_state + Hash.new { |state, user_id| state[user_id] = { answer_ids: [], response_ids: Set.new } } end - end - def fetch_item_ids_associated_with_deployment_questionnaire - Item.for_questionnaire_and_type(@deployment.questionnaire_id, @deployment.question_type).pluck(:id) + def accumulate(state, team_id, answer) + (@users_by_team[team_id] || []).each do |teams_user| + state[teams_user.user_id][:answer_ids] << answer.id + state[teams_user.user_id][:response_ids].add(answer.response_id) + end + end end - # Returns { team_id => [response_id, ...] } across all rubrics - # on this assignment. Scoping to the deployment's questionnaire happens - # via item_ids in fetch_taggable_answer_ids_by_team, not here. - def fetch_responses_received_by_teams_across_questionnaires_for_deployment_assignment - team_response_pairs = Response - .submitted_review_responses_for(@reportable.id) - .pluck('response_maps.reviewee_id', 'responses.id') - - response_ids_by_team = Hash.new { |by_team, team_id| by_team[team_id] = [] } - team_response_pairs.each do |team_id, response_id| - response_ids_by_team[team_id] << response_id + # ----------------------------------------------------------------------- + # Pipeline 2 — per-user answer tags with response context. + # + # Streams all AnswerTag rows for this deployment (joined with answers to + # get response_id). No item or threshold filtering in SQL — the finalize + # step filters tags by comparing their response_id against the taggable + # response_ids from Pipeline 1. + # + # Output: { user_id => [{ answer_id:, response_id:, updated_at: }] } + # ----------------------------------------------------------------------- + class TaggingStatsPipeline < BaseReport + def initialize(reportable, deployment) + super(reportable) + @deployment = deployment + end + + def source + AnswerTag + .for_deployment(@deployment.id) + .joins(:answer) + .select('answer_tags.id, answer_tags.user_id, answer_tags.answer_id, answer_tags.updated_at, answers.response_id') + end + + def grouper = ->(tag) { tag.user_id } + + def initial_state + Hash.new { |state, user_id| state[user_id] = [] } + end + + def accumulate(state, user_id, tag) + state[user_id] << { answer_id: tag.answer_id, response_id: tag.response_id, updated_at: tag.updated_at } end - response_ids_by_team end end # ------------------------------------------------------------------------- - # Pipeline 2 — cross-deployment per-user summary. + # Pipeline 3 — cross-deployment per-user summary. # Consumes DeploymentPipeline output; no additional DB queries. # ------------------------------------------------------------------------- class UserSummaryPipeline @@ -166,12 +216,12 @@ def run summary = {} @per_deployment.each_value do |deployment_data| deployment_data[:user_stats].each do |stat| - key = stat[:name] + key = stat[:user_id] if summary.key?(key) entry = summary[key] - entry[:cnt_tagged] += stat[:cnt_tagged] + entry[:cnt_tagged] += stat[:cnt_tagged] entry[:cnt_not_tagged] += stat[:cnt_not_tagged] - entry[:cnt_taggable] += stat[:cnt_taggable] + entry[:cnt_taggable] += stat[:cnt_taggable] entry[:percentage] = entry[:cnt_taggable].zero? ? '-' : format('%.1f', entry[:cnt_tagged].to_f / entry[:cnt_taggable] * 100) else summary[key] = stat.slice(:user_id, :name, :full_name, :cnt_tagged, :cnt_not_tagged, :cnt_taggable, :percentage) From 36d78be2c03d70d5804e1ba4bdeeae6f801dd050 Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Sun, 24 May 2026 13:06:33 -0400 Subject: [PATCH 10/41] Simplify AnswerTaggingReport to match tags by answer_id instead of response_id TaggableAnswersPipeline state simplified from {answer_ids, response_ids} to a plain array of answer_ids. Finalize converts to a Set and filters AnswerTags directly by answer_id membership, removing the response_id indirection and the unused response_ids Set from the pipeline. Co-Authored-By: Claude Sonnet 4.6 --- app/models/reports/answer_tagging_report.rb | 30 ++++++++++----------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/app/models/reports/answer_tagging_report.rb b/app/models/reports/answer_tagging_report.rb index eefbdc801..903cf3aec 100644 --- a/app/models/reports/answer_tagging_report.rb +++ b/app/models/reports/answer_tagging_report.rb @@ -52,8 +52,8 @@ def run # # TaggableAnswersPipeline — streams taggable Answer rows (joined with # response_maps, filtered by item type and threshold). Returns per-user - # lists of taggable answer_ids and their response_ids. - # Output: { user_id => { answer_ids: [], response_ids: Set[] } } + # lists of taggable answer_ids. + # Output: { user_id => [answer_ids] } # # TaggingStatsPipeline — streams all AnswerTag rows for this deployment # (joined with answers to get response_id). No item/threshold filtering @@ -87,16 +87,15 @@ def finalize(taggable_data, tagging_stats) end user_stats = user_info.map do |user_id, info| - taggable = taggable_data.fetch(user_id, {}) - cnt_taggable = (taggable[:answer_ids] || []).size - taggable_response_ids = taggable[:response_ids] || Set.new + taggable_answer_ids = taggable_data.fetch(user_id, []).to_set + cnt_taggable = taggable_answer_ids.size - # Filter tags to only those belonging to taggable responses for this user. + # Filter tags to only those on answers that are taggable for this user. # TODO: confirm with prof — if a reviewer submits multiple responses for the # same round, only the latest submitted response should be counted as taggable. - # TaggableAnswersPipeline may need to deduplicate response_ids by keeping only - # the most recently submitted response per (reviewer, round). - matching_tags = tagging_stats.fetch(user_id, []).select { |tag| taggable_response_ids.include?(tag[:response_id]) } + # TaggableAnswersPipeline may need to deduplicate by keeping only the most + # recently submitted response per (reviewer, round). + matching_tags = tagging_stats.fetch(user_id, []).select { |tag| taggable_answer_ids.include?(tag[:answer_id]) } cnt_tagged = matching_tags.size cnt_not_tagged = cnt_taggable - cnt_tagged @@ -125,14 +124,14 @@ def finalize(taggable_data, tagging_stats) end # ----------------------------------------------------------------------- - # Pipeline 1 — per-user taggable answer and response IDs. + # Pipeline 1 — per-user taggable answer IDs. # # Streams taggable Answer rows (joined with responses and response_maps, # filtered by item type and length threshold). Each row is one (answer, team) # pair — answers.id is unique per row so find_each paginates correctly. - # For each row, adds the answer_id and response_id to all users of the team. + # For each row, adds the answer_id to all users of the team. # - # Output: { user_id => { answer_ids: [], response_ids: Set[] } } + # Output: { user_id => [answer_ids] } # ----------------------------------------------------------------------- class TaggableAnswersPipeline < BaseReport def initialize(reportable, deployment, item_ids, users_by_team) @@ -151,19 +150,18 @@ def source type: 'ReviewResponseMap', threshold: @deployment.answer_length_threshold ) - .select('answers.id, answers.response_id, response_maps.reviewee_id as team_id') + .select('answers.id, response_maps.reviewee_id as team_id') end def grouper = ->(answer) { answer.team_id } def initial_state - Hash.new { |state, user_id| state[user_id] = { answer_ids: [], response_ids: Set.new } } + Hash.new { |state, user_id| state[user_id] = [] } end def accumulate(state, team_id, answer) (@users_by_team[team_id] || []).each do |teams_user| - state[teams_user.user_id][:answer_ids] << answer.id - state[teams_user.user_id][:response_ids].add(answer.response_id) + state[teams_user.user_id] << answer.id end end end From ea432c5e9a7aa362f27c182be95a9df402ccc04d Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Sun, 24 May 2026 13:13:47 -0400 Subject: [PATCH 11/41] Fix ReviewReport bug and precompute max_q_scores once for both pipelines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix NameError bug (return unless r → return unless reviewer) in ReviewersPipeline. Remove stale grouper comment. Move max_q_scores precomputation from each pipeline's initialize into ReviewReport#run so it runs once and is passed to both ScoresPipeline and AvgRangesPipeline, eliminating the duplicate DB query. ReviewResponsePipelineShared now only holds the shared source definition. Co-Authored-By: Claude Sonnet 4.6 --- app/models/reports/review_report.rb | 43 +++++++++++++---------------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/app/models/reports/review_report.rb b/app/models/reports/review_report.rb index b1302cb33..203f637d8 100644 --- a/app/models/reports/review_report.rb +++ b/app/models/reports/review_report.rb @@ -8,8 +8,8 @@ module Reports # # Each pipeline streams its own source relation via find_each, so no # full result set is ever materialised in Ruby at once. - # max_question_score is precomputed once per (assignment, round) to avoid - # an N+1 query inside the hot accumulate loop. + # max_question_score is precomputed once in #run and passed to both score + # pipelines to avoid a duplicate DB query and N+1 inside the accumulate loop. class ReviewReport def self.for_assignment(assignment) new(assignment) @@ -20,30 +20,26 @@ def initialize(reportable) end def run + # Precomputed once and passed to both score pipelines to avoid a duplicate DB query. + max_q_scores = AssignmentQuestionnaire + .for_assignment_and_type(@reportable.id, 'ReviewQuestionnaire') + .pluck(:used_in_round, 'questionnaires.max_question_score') + .to_h { - reviewers: ReviewersPipeline.new(@reportable).run, - review_scores: ScoresPipeline.new(@reportable).run, - avg_and_ranges: AvgRangesPipeline.new(@reportable).run + reviewers: ReviewersPipeline.new(@reportable).run, + review_scores: ScoresPipeline.new(@reportable, max_q_scores).run, + avg_and_ranges: AvgRangesPipeline.new(@reportable, max_q_scores).run } end - # Shared source and max-score precomputation for ScoresPipeline and AvgRangesPipeline. - # Both pipelines stream the same submitted ReviewResponseMap responses and need - # the same max question score lookup — extracted here to avoid duplication. + # Shared source for ScoresPipeline and AvgRangesPipeline. + # Both pipelines stream the same submitted ReviewResponseMap responses. module ReviewResponsePipelineShared def source Response .submitted_review_responses_for(@reportable.id) .includes(:response_map, scores: :item) end - - def precompute_max_q_scores - # Check with prof if we need to use score_views?? - AssignmentQuestionnaire - .for_assignment_and_type(@reportable.id, 'ReviewQuestionnaire') - .pluck(:used_in_round, 'questionnaires.max_question_score') - .to_h - end end # ----------------------------------------------------------------------- @@ -55,7 +51,6 @@ def source ReviewResponseMap.for_assignment(@reportable.id).includes(reviewer: :user) end - # It pre-computes the grouping key once and passes it to accumulate so the subclass doesn't have to recompute it. def grouper = ->(response_map) { response_map.reviewer_id } def initial_state = {} @@ -64,7 +59,7 @@ def accumulate(state, reviewer_id, response_map) return if state.key?(reviewer_id) reviewer = response_map.reviewer - return unless r + return unless reviewer state[reviewer_id] = { id: reviewer.id, @@ -88,9 +83,9 @@ def finalize(state) class ScoresPipeline < BaseReport include ReviewResponsePipelineShared - def initialize(reportable) - super - @max_q_score = precompute_max_q_scores + def initialize(reportable, max_q_scores) + super(reportable) + @max_q_score = max_q_scores end def grouper = ->(response) { response.response_map.reviewer_id } @@ -130,9 +125,9 @@ def accumulate(state, reviewer_id, response) class AvgRangesPipeline < BaseReport include ReviewResponsePipelineShared - def initialize(reportable) - super - @max_q_score = precompute_max_q_scores + def initialize(reportable, max_q_scores) + super(reportable) + @max_q_score = max_q_scores end def grouper = ->(response) { response.response_map.reviewee_id } From 94cac892e903c27080bc7fa72554791d7489d6eb Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Sun, 24 May 2026 13:17:03 -0400 Subject: [PATCH 12/41] Expand BaseReport comments to document grouper and accumulate roles Add explanation of grouper's separation of concerns, how BaseReport#run wires grouper key into accumulate, and concrete examples from ScoresPipeline, AvgRangesPipeline, and TaggableAnswersPipeline. Also document that all domain math belongs in accumulate, not the base. Co-Authored-By: Claude Sonnet 4.6 --- app/models/reports/base_report.rb | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/app/models/reports/base_report.rb b/app/models/reports/base_report.rb index 5bf4a708b..dc0794a49 100644 --- a/app/models/reports/base_report.rb +++ b/app/models/reports/base_report.rb @@ -18,8 +18,23 @@ module Reports # Subclasses must implement (private): # source → AR relation (consumed via find_each) # grouper → lambda(row) → grouping key + # Separates "what bucket does this row belong to?" from + # "what do I do with a row in that bucket?" (accumulate). + # BaseReport#run calls grouper.call(row) and passes the + # result as the key to accumulate — subclasses get this + # wiring for free and can see at a glance what each + # pipeline is aggregating over. Examples: + # ScoresPipeline groups by reviewer_id — all responses + # from the same reviewer go into the same bucket + # AvgRangesPipeline groups by reviewee_id — all responses + # received by the same team go into the same bucket + # TaggableAnswersPipeline groups by team_id — all answers + # received by the same team go into the same bucket # initial_state → empty accumulator value - # accumulate(state, key, row) → mutates state in place + # accumulate(state, key, row) → mutates state in place; key is the result + # of grouper.call(row). Answers "what do I do with a row + # in this bucket?" — all domain math lives here, not in + # the base class. # # Subclasses may override (private): # finalize(state) → transforms finished state into the output hash From 1d96437abfbcd66eb452a3c1a7327631e69f576b Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Mon, 25 May 2026 11:46:40 -0400 Subject: [PATCH 13/41] Rename grouper to state_key_for and deduplicate review responses by latest submission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename grouper → state_key_for across all report aggregators and BaseReport to better express its role: given a row, return the key used to look it up in the accumulated state. Update submitted_review_responses_for scope to return only the latest submitted response per (response_map, round) via a MAX(id) subquery, ensuring that only the most recent submission per reviewer-reviewee pair per round is counted in ScoresAggregator and AvgRangesAggregator. Rename Pipeline → Aggregator in all comments and class references across answer_tagging_report.rb, review_report.rb, and base_report.rb. Co-Authored-By: Claude Sonnet 4.6 --- app/models/reports/answer_tagging_report.rb | 56 ++++++------- app/models/reports/base_report.rb | 30 +++---- app/models/reports/bookmark_rating_report.rb | 28 +++++++ app/models/reports/feedback_report.rb | 86 ++++++++++++++++++++ app/models/reports/review_report.rb | 66 +++++++-------- app/models/reports/teammate_review_report.rb | 38 +++++++++ app/models/response.rb | 15 +++- 7 files changed, 239 insertions(+), 80 deletions(-) create mode 100644 app/models/reports/bookmark_rating_report.rb create mode 100644 app/models/reports/feedback_report.rb create mode 100644 app/models/reports/teammate_review_report.rb diff --git a/app/models/reports/answer_tagging_report.rb b/app/models/reports/answer_tagging_report.rb index 903cf3aec..d2458b396 100644 --- a/app/models/reports/answer_tagging_report.rb +++ b/app/models/reports/answer_tagging_report.rb @@ -31,15 +31,15 @@ def initialize(assignment) def run per_deployment = {} - # The coordinator runs one DeploymentPipeline per TagPromptDeployment, - # then passes the results to UserSummaryPipeline for cross-deployment totals. + # The coordinator runs one DeploymentAggregator per TagPromptDeployment, + # then passes the results to UserSummaryAggregator for cross-deployment totals. TagPromptDeployment .where(assignment_id: @reportable.id) .includes(:tag_prompt, :questionnaire) .each do |deployment| - per_deployment[deployment.id] = DeploymentPipeline.new(@reportable, deployment).run + per_deployment[deployment.id] = DeploymentAggregator.new(@reportable, deployment).run end - user_summary = UserSummaryPipeline.new(per_deployment).run + user_summary = UserSummaryAggregator.new(per_deployment).run { questionnaire_tagging_report: per_deployment, user_tagging_report: user_summary @@ -47,25 +47,25 @@ def run end # ------------------------------------------------------------------------- - # Coordinator — runs two streaming pipelines for one TagPromptDeployment + # Coordinator — runs two streaming aggregators for one TagPromptDeployment # and merges their results: # - # TaggableAnswersPipeline — streams taggable Answer rows (joined with + # TaggableAnswersAggregator — streams taggable Answer rows (joined with # response_maps, filtered by item type and threshold). Returns per-user # lists of taggable answer_ids. # Output: { user_id => [answer_ids] } # - # TaggingStatsPipeline — streams all AnswerTag rows for this deployment - # (joined with answers to get response_id). No item/threshold filtering - # in SQL — filtering happens in finalize by comparing response_ids. + # TaggingStatsAggregator — streams all AnswerTag rows for this deployment + # (joined with answers to get answer_id). No item/threshold filtering + # in SQL — filtering happens in finalize by comparing answer_ids. # Output: { user_id => [{ answer_id:, response_id:, updated_at: }] } # - # Precomputed once, shared across both pipelines: + # Precomputed once, shared across both aggregators: # @item_ids — IDs of items in the deployment's questionnaire (tiny) # @users_by_team — TeamsUser records grouped by team_id; also used in # finalize to build per-user name info # ------------------------------------------------------------------------- - class DeploymentPipeline + class DeploymentAggregator def initialize(reportable, deployment) @reportable = reportable @deployment = deployment @@ -74,8 +74,8 @@ def initialize(reportable, deployment) end def run - taggable_data = TaggableAnswersPipeline.new(@reportable, @deployment, @item_ids, @users_by_team).run - tagging_stats = TaggingStatsPipeline.new(@reportable, @deployment).run + taggable_data = TaggableAnswersAggregator.new(@reportable, @deployment, @item_ids, @users_by_team).run + tagging_stats = TaggingStatsAggregator.new(@reportable, @deployment).run finalize(taggable_data, tagging_stats) end @@ -93,9 +93,9 @@ def finalize(taggable_data, tagging_stats) # Filter tags to only those on answers that are taggable for this user. # TODO: confirm with prof — if a reviewer submits multiple responses for the # same round, only the latest submitted response should be counted as taggable. - # TaggableAnswersPipeline may need to deduplicate by keeping only the most + # TaggableAnswersAggregator may need to deduplicate by keeping only the most # recently submitted response per (reviewer, round). - matching_tags = tagging_stats.fetch(user_id, []).select { |tag| taggable_answer_ids.include?(tag[:answer_id]) } + matching_tags = tagging_stats.fetch(user_id, []).select { |tag| taggable_answer_ids.include?(tag[:answer_id]) } cnt_tagged = matching_tags.size cnt_not_tagged = cnt_taggable - cnt_tagged @@ -124,7 +124,7 @@ def finalize(taggable_data, tagging_stats) end # ----------------------------------------------------------------------- - # Pipeline 1 — per-user taggable answer IDs. + # Aggregator 1 — per-user taggable answer IDs. # # Streams taggable Answer rows (joined with responses and response_maps, # filtered by item type and length threshold). Each row is one (answer, team) @@ -133,7 +133,7 @@ def finalize(taggable_data, tagging_stats) # # Output: { user_id => [answer_ids] } # ----------------------------------------------------------------------- - class TaggableAnswersPipeline < BaseReport + class TaggableAnswersAggregator < BaseReport def initialize(reportable, deployment, item_ids, users_by_team) super(reportable) @deployment = deployment @@ -153,7 +153,7 @@ def source .select('answers.id, response_maps.reviewee_id as team_id') end - def grouper = ->(answer) { answer.team_id } + def state_key_for = ->(answer) { answer.team_id } def initial_state Hash.new { |state, user_id| state[user_id] = [] } @@ -167,16 +167,16 @@ def accumulate(state, team_id, answer) end # ----------------------------------------------------------------------- - # Pipeline 2 — per-user answer tags with response context. + # Aggregator 2 — per-user answer tags with answer context. # - # Streams all AnswerTag rows for this deployment (joined with answers to - # get response_id). No item or threshold filtering in SQL — the finalize - # step filters tags by comparing their response_id against the taggable - # response_ids from Pipeline 1. + # Streams all AnswerTag rows for this deployment (joined with answers). + # No item or threshold filtering in SQL — the finalize step filters tags + # by comparing their answer_id against the taggable answer_ids from + # Aggregator 1. # # Output: { user_id => [{ answer_id:, response_id:, updated_at: }] } # ----------------------------------------------------------------------- - class TaggingStatsPipeline < BaseReport + class TaggingStatsAggregator < BaseReport def initialize(reportable, deployment) super(reportable) @deployment = deployment @@ -189,7 +189,7 @@ def source .select('answer_tags.id, answer_tags.user_id, answer_tags.answer_id, answer_tags.updated_at, answers.response_id') end - def grouper = ->(tag) { tag.user_id } + def state_key_for = ->(tag) { tag.user_id } def initial_state Hash.new { |state, user_id| state[user_id] = [] } @@ -202,10 +202,10 @@ def accumulate(state, user_id, tag) end # ------------------------------------------------------------------------- - # Pipeline 3 — cross-deployment per-user summary. - # Consumes DeploymentPipeline output; no additional DB queries. + # Aggregator 3 — cross-deployment per-user summary. + # Consumes DeploymentAggregator output; no additional DB queries. # ------------------------------------------------------------------------- - class UserSummaryPipeline + class UserSummaryAggregator def initialize(per_deployment_result) @per_deployment = per_deployment_result end diff --git a/app/models/reports/base_report.rb b/app/models/reports/base_report.rb index dc0794a49..70b975317 100644 --- a/app/models/reports/base_report.rb +++ b/app/models/reports/base_report.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Reports - # Template for a streaming reduce-based report pipeline. + # Template for a streaming reduce-based report aggregator. # # Design rationale (addresses two anti-patterns from the naive approach): # @@ -12,27 +12,27 @@ module Reports # # Anti-pattern 2 — "default metrics in base": encoding avg_score or any # domain metric in the base class ties every report to one shape of math. - # This class contains *only* the pipeline scaffold; each subclass owns its + # This class contains *only* the aggregator scaffold; each subclass owns its # accumulate/finalize logic entirely. # # Subclasses must implement (private): # source → AR relation (consumed via find_each) - # grouper → lambda(row) → grouping key + # state_key_for → lambda(row) → state bucket key # Separates "what bucket does this row belong to?" from # "what do I do with a row in that bucket?" (accumulate). - # BaseReport#run calls grouper.call(row) and passes the + # BaseReport#run calls state_key_for.call(row) and passes the # result as the key to accumulate — subclasses get this # wiring for free and can see at a glance what each - # pipeline is aggregating over. Examples: - # ScoresPipeline groups by reviewer_id — all responses + # aggregator is aggregating over. Examples: + # ScoresAggregator groups by reviewer_id — all responses # from the same reviewer go into the same bucket - # AvgRangesPipeline groups by reviewee_id — all responses + # AvgRangesAggregator groups by reviewee_id — all responses # received by the same team go into the same bucket - # TaggableAnswersPipeline groups by team_id — all answers + # TaggableAnswersAggregator groups by team_id — all answers # received by the same team go into the same bucket # initial_state → empty accumulator value # accumulate(state, key, row) → mutates state in place; key is the result - # of grouper.call(row). Answers "what do I do with a row + # of state_key_for.call(row). Answers "what do I do with a row # in this bucket?" — all domain math lives here, not in # the base class. # @@ -52,24 +52,24 @@ def self.for_course(course) # @param reportable [Assignment, Course] the object the report is scoped to. # Subclasses reference @reportable instead of a type-specific variable so - # the same pipeline works for any reportable entity. + # the same aggregator works for any reportable entity. def initialize(reportable) @reportable = reportable end - # Runs the pipeline: stream → group → accumulate → finalize. + # Runs the aggregator: stream → group → accumulate → finalize. # # Benefits of this structure over writing report code directly: # 1. Memory safety — find_each streams in batches of 500 rather than # loading the entire relation into Ruby. Every report gets this for free. - # 2. New reports are just data — subclasses define source/grouper/accumulate/ - # finalize; the pipeline wiring is not their concern. + # 2. New reports are just data — subclasses define source/state_key_for/accumulate/ + # finalize; the aggregator wiring is not their concern. # 3. Single place for cross-cutting concerns — logging, timing, or error # handling can be added here once and applies to every report. def run state = initial_state source.find_each(batch_size: 500) do |row| - accumulate(state, grouper.call(row), row) + accumulate(state, state_key_for.call(row), row) end finalize(state) end @@ -77,7 +77,7 @@ def run private def source = raise NotImplementedError, "#{self.class}#source" - def grouper = raise NotImplementedError, "#{self.class}#grouper" + def state_key_for = raise NotImplementedError, "#{self.class}#state_key_for" def initial_state = raise NotImplementedError, "#{self.class}#initial_state" def accumulate(_state, _key, _row) diff --git a/app/models/reports/bookmark_rating_report.rb b/app/models/reports/bookmark_rating_report.rb new file mode 100644 index 000000000..a6cdc95e9 --- /dev/null +++ b/app/models/reports/bookmark_rating_report.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Reports + # Bookmark-rating report: which bookmarks were rated under this assignment + # and the associated project topics. + # + # Accumulator: groups BookmarkRatingResponseMap rows by reviewee_id (the + # bookmark being rated) and collects distinct bookmark IDs in one pass. + # Project topics are fetched separately (one query) since they are not + # streamed per row. + class BookmarkRatingReport < BaseReport + def source + BookmarkRatingResponseMap.where(reviewed_object_id: @reportable.id) + end + + def state_key_for = ->(map) { map.reviewee_id } + def initial_state = Set.new + + def accumulate(state, bookmark_id, _map) + state.add(bookmark_id) + end + + def finalize(bookmark_ids) + topics = @reportable.project_topics.map { |t| { id: t.id, topic_name: t.topic_name } } + { bookmark_ids: bookmark_ids.to_a, topics: topics } + end + end +end diff --git a/app/models/reports/feedback_report.rb b/app/models/reports/feedback_report.rb new file mode 100644 index 000000000..122a07303 --- /dev/null +++ b/app/models/reports/feedback_report.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Reports + # Author-feedback report: shows the latest review response IDs that received + # author feedback, bucketed by round for varying-rubric assignments. + # + # Pipeline: + # source — Responses on ReviewResponseMaps for this assignment, + # ordered newest-first so the first occurrence per (map, round) + # is the latest revision. + # state_key_for — [map_id, round]: deduplication key (one response per map per round) + # accumulate — skips duplicates; buckets response IDs by round + # finalize — fetches authors (one query), returns shaped hash + class FeedbackReport < BaseReport + def source + Response + .joins(:response_map) + .where(response_maps: { type: 'ReviewResponseMap', reviewed_object_id: @reportable.id }) + .order(created_at: :desc) + end + + def state_key_for = ->(r) { [r.map_id, r.round] } + + def initial_state + { seen: Set.new, round_1: [], round_2: [], round_3: [], all: [] } + end + + def accumulate(state, key, response) + return if state[:seen].include?(key) + + state[:seen].add(key) + + if @reportable.varying_rubrics_by_round? + case response.round + when 1 then state[:round_1] << response.id + when 2 then state[:round_2] << response.id + when 3 then state[:round_3] << response.id + end + else + state[:all] << response.id + end + end + + def finalize(state) + authors = fetch_authors + + if @reportable.varying_rubrics_by_round? + { + authors: authors.map { |a| format_participant(a) }, + review_response_ids: { + round_1: state[:round_1], + round_2: state[:round_2], + round_3: state[:round_3] + } + } + else + { + authors: authors.map { |a| format_participant(a) }, + review_response_ids: state[:all] + } + end + end + + private + + def fetch_authors + teams = AssignmentTeam.includes(:users).where(parent_id: @reportable.id) + teams.flat_map do |team| + team.users.filter_map do |user| + AssignmentParticipant.find_by(parent_id: @reportable.id, user_id: user.id) + end + end + end + + def format_participant(p) + return {} unless p + + { + id: p.id, + user_id: p.user_id, + name: p.user&.name, + full_name: p.user&.full_name + } + end + end +end diff --git a/app/models/reports/review_report.rb b/app/models/reports/review_report.rb index 203f637d8..7e56f379b 100644 --- a/app/models/reports/review_report.rb +++ b/app/models/reports/review_report.rb @@ -1,15 +1,15 @@ # frozen_string_literal: true module Reports - # Peer-review report composed of three independent streaming pipelines: - # 1 ReviewersPipeline — distinct reviewers with user details - # 2 ScoresPipeline — per-reviewer × round × reviewee score pct - # 3 AvgRangesPipeline — per-team × round max / min / avg + # Peer-review report composed of three independent streaming aggregators: + # 1 ReviewersAggregator — distinct reviewers with user details + # 2 ScoresAggregator — per-reviewer × round × reviewee score pct + # 3 AvgRangesAggregator — per-team × round max / min / avg # - # Each pipeline streams its own source relation via find_each, so no + # Each aggregator streams its own source relation via find_each, so no # full result set is ever materialised in Ruby at once. # max_question_score is precomputed once in #run and passed to both score - # pipelines to avoid a duplicate DB query and N+1 inside the accumulate loop. + # aggregators to avoid a duplicate DB query and N+1 inside the accumulate loop. class ReviewReport def self.for_assignment(assignment) new(assignment) @@ -20,21 +20,21 @@ def initialize(reportable) end def run - # Precomputed once and passed to both score pipelines to avoid a duplicate DB query. + # Precomputed once and passed to both score aggregators to avoid a duplicate DB query. max_q_scores = AssignmentQuestionnaire .for_assignment_and_type(@reportable.id, 'ReviewQuestionnaire') .pluck(:used_in_round, 'questionnaires.max_question_score') .to_h { - reviewers: ReviewersPipeline.new(@reportable).run, - review_scores: ScoresPipeline.new(@reportable, max_q_scores).run, - avg_and_ranges: AvgRangesPipeline.new(@reportable, max_q_scores).run + reviewers: ReviewersAggregator.new(@reportable).run, + review_scores: ScoresAggregator.new(@reportable, max_q_scores).run, + avg_and_ranges: AvgRangesAggregator.new(@reportable, max_q_scores).run } end - # Shared source for ScoresPipeline and AvgRangesPipeline. - # Both pipelines stream the same submitted ReviewResponseMap responses. - module ReviewResponsePipelineShared + # Shared source for ScoresAggregator and AvgRangesAggregator. + # Both aggregators stream the same submitted ReviewResponseMap responses. + module ReviewResponseShared def source Response .submitted_review_responses_for(@reportable.id) @@ -43,15 +43,15 @@ def source end # ----------------------------------------------------------------------- - # Pipeline 1 — distinct reviewers sorted by full name. + # Aggregator 1 — distinct reviewers sorted by full name. # Groups by reviewer_id; first occurrence per reviewer is kept. # ----------------------------------------------------------------------- - class ReviewersPipeline < BaseReport + class ReviewersAggregator < BaseReport def source ReviewResponseMap.for_assignment(@reportable.id).includes(reviewer: :user) end - def grouper = ->(response_map) { response_map.reviewer_id } + def state_key_for = ->(response_map) { response_map.reviewer_id } def initial_state = {} @@ -62,11 +62,11 @@ def accumulate(state, reviewer_id, response_map) return unless reviewer state[reviewer_id] = { - id: reviewer.id, - user_id: reviewer.user_id, - name: reviewer.user&.name, + id: reviewer.id, + user_id: reviewer.user_id, + name: reviewer.user&.name, full_name: reviewer.user&.full_name, - handle: reviewer.handle + handle: reviewer.handle } end @@ -76,19 +76,19 @@ def finalize(state) end # ----------------------------------------------------------------------- - # Pipeline 2 — per-reviewer × round × reviewee score percentages. + # Aggregator 2 — per-reviewer × round × reviewee score percentages. # Streams submitted Responses with scores and items eagerly loaded. # Output: { reviewer_id => { round => { reviewee_id => pct } } } # ----------------------------------------------------------------------- - class ScoresPipeline < BaseReport - include ReviewResponsePipelineShared + class ScoresAggregator < BaseReport + include ReviewResponseShared def initialize(reportable, max_q_scores) super(reportable) @max_q_score = max_q_scores end - def grouper = ->(response) { response.response_map.reviewer_id } + def state_key_for = ->(response) { response.response_map.reviewer_id } def initial_state # Three-level nested hash: reviewer_id => round => reviewee_id => score_pct @@ -106,10 +106,10 @@ def accumulate(state, reviewer_id, response) total_wt = answered.sum { |s| s.item.weight } return if total_wt.zero? - round = response.round || 1 + round = response.round || 1 reviewee_id = response.response_map.reviewee_id - raw_score = answered.sum { |s| s.answer * s.item.weight } - max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1) + raw_score = answered.sum { |s| s.answer * s.item.weight } + max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1) return unless max_score.positive? state[reviewer_id][round][reviewee_id] = ((raw_score.to_f / max_score) * 100).round(2) @@ -117,20 +117,20 @@ def accumulate(state, reviewer_id, response) end # ----------------------------------------------------------------------- - # Pipeline 3 — per-team × round aggregate (max / min / avg). - # Same response stream as Pipeline 2; groups by reviewee_id. + # Aggregator 3 — per-team × round aggregate (max / min / avg). + # Same response stream as Aggregator 2; groups by reviewee_id. # Output: { team_id => { round => { max:, min:, avg: } } } # ----------------------------------------------------------------------- # Check with prof how they are planning to use this information?? Is it for AVG score column? - class AvgRangesPipeline < BaseReport - include ReviewResponsePipelineShared + class AvgRangesAggregator < BaseReport + include ReviewResponseShared def initialize(reportable, max_q_scores) super(reportable) @max_q_score = max_q_scores end - def grouper = ->(response) { response.response_map.reviewee_id } + def state_key_for = ->(response) { response.response_map.reviewee_id } def initial_state # Two-level nested hash: reviewee_id => round => [score_pcts] @@ -145,7 +145,7 @@ def accumulate(state, reviewee_id, response) total_wt = answered.sum { |s| s.item.weight } return if total_wt.zero? - round = response.round || 1 + round = response.round || 1 raw_score = answered.sum { |s| s.answer * s.item.weight } max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1) return unless max_score.positive? diff --git a/app/models/reports/teammate_review_report.rb b/app/models/reports/teammate_review_report.rb new file mode 100644 index 000000000..71944a91d --- /dev/null +++ b/app/models/reports/teammate_review_report.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Reports + # Teammate-review report: lists distinct reviewers who submitted teammate + # evaluations for the assignment. + # + # Accumulator: groups TeammateReviewResponseMap rows by reviewer_id, keeping + # one entry per reviewer. reviewer associations are eagerly loaded to avoid + # N+1 queries. + class TeammateReviewReport < BaseReport + def source + TeammateReviewResponseMap + .where(reviewed_object_id: @reportable.id) + .includes(reviewer: :user) + end + + def state_key_for = ->(map) { map.reviewer_id } + def initial_state = {} + + def accumulate(state, reviewer_id, map) + return if state.key?(reviewer_id) + + participant = map.reviewer + return unless participant + + state[reviewer_id] = { + reviewer_id: participant.id, + user_id: participant.user_id, + name: participant.user&.name, + full_name: participant.user&.full_name + } + end + + def finalize(state) + { reviewers: state.values } + end + end +end diff --git a/app/models/response.rb b/app/models/response.rb index 5ab9c00a3..533b877a9 100644 --- a/app/models/response.rb +++ b/app/models/response.rb @@ -7,10 +7,17 @@ class Response < ApplicationRecord belongs_to :response_map, class_name: 'ResponseMap', foreign_key: 'map_id', inverse_of: false scope :submitted_review_responses_for, ->(assignment_id) { - joins(:response_map).where( - response_maps: { reviewed_object_id: assignment_id, type: 'ReviewResponseMap' }, - is_submitted: true - ) + # Subquery picks the latest submitted response per (response_map, round) + # so that only the most recent submission per reviewer-reviewee pair per round is counted. + latest_ids = joins(:response_map) + .where( + response_maps: { reviewed_object_id: assignment_id, type: 'ReviewResponseMap' }, + is_submitted: true + ) + .group('response_maps.id, responses.round') + .select('MAX(responses.id)') + + where(id: latest_ids) } has_many :scores, class_name: 'Answer', foreign_key: 'response_id', dependent: :destroy, inverse_of: false From eeaa06758cbb707d1ac255657441788b2d272ba2 Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Mon, 25 May 2026 12:02:54 -0400 Subject: [PATCH 14/41] Update TeammateReviewReport to accumulate reviewees per reviewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store all reviewees for each reviewer instead of keeping only one entry per reviewer. Eagerly load reviewee: :user alongside reviewer: :user to avoid N+1 queries. Rename participant → reviewer for clarity. Co-Authored-By: Claude Sonnet 4.6 --- app/models/reports/teammate_review_report.rb | 40 ++++++++++++-------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/app/models/reports/teammate_review_report.rb b/app/models/reports/teammate_review_report.rb index 71944a91d..6b68971c1 100644 --- a/app/models/reports/teammate_review_report.rb +++ b/app/models/reports/teammate_review_report.rb @@ -1,33 +1,43 @@ # frozen_string_literal: true module Reports - # Teammate-review report: lists distinct reviewers who submitted teammate - # evaluations for the assignment. + # Teammate-review report: lists reviewers with all reviewees they evaluated + # for the assignment. # - # Accumulator: groups TeammateReviewResponseMap rows by reviewer_id, keeping - # one entry per reviewer. reviewer associations are eagerly loaded to avoid - # N+1 queries. + # Accumulator: groups TeammateReviewResponseMap rows by reviewer_id. On first + # occurrence, initializes the reviewer entry with an empty reviewees list. On + # every occurrence, appends the reviewee to that list. + # reviewer and reviewee associations are eagerly loaded to avoid N+1 queries. class TeammateReviewReport < BaseReport def source TeammateReviewResponseMap .where(reviewed_object_id: @reportable.id) - .includes(reviewer: :user) + .includes(reviewer: :user, reviewee: :user) end - def state_key_for = ->(map) { map.reviewer_id } + def state_key_for = ->(map) { map.reviewer_id } + def initial_state = {} def accumulate(state, reviewer_id, map) - return if state.key?(reviewer_id) + reviewer = map.reviewer + return unless reviewer + + state[reviewer_id] ||= { + reviewer_id: reviewer.id, + user_id: reviewer.user_id, + name: reviewer.user&.name, + full_name: reviewer.user&.full_name, + reviewees: [] + } - participant = map.reviewer - return unless participant + reviewee = map.reviewee + return unless reviewee - state[reviewer_id] = { - reviewer_id: participant.id, - user_id: participant.user_id, - name: participant.user&.name, - full_name: participant.user&.full_name + state[reviewer_id][:reviewees] << { + reviewee_id: reviewee.id, + name: reviewee.user&.name, + full_name: reviewee.user&.full_name } end From 10a6a4ce0c3f8908b7d70fd127559f4380e68f8c Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Mon, 25 May 2026 12:06:31 -0400 Subject: [PATCH 15/41] Document that BaseReport does not enforce state_key_for key usage in accumulate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add caveat to accumulate description noting that the key is passed but not enforced — subclasses are trusted to use it as the state bucket key. Co-Authored-By: Claude Sonnet 4.6 --- app/models/reports/base_report.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/models/reports/base_report.rb b/app/models/reports/base_report.rb index 70b975317..f444182e4 100644 --- a/app/models/reports/base_report.rb +++ b/app/models/reports/base_report.rb @@ -34,7 +34,10 @@ module Reports # accumulate(state, key, row) → mutates state in place; key is the result # of state_key_for.call(row). Answers "what do I do with a row # in this bucket?" — all domain math lives here, not in - # the base class. + # the base class. Note: BaseReport passes the key but does not + # enforce that accumulate uses it — subclasses are trusted to + # use it as the state bucket key; using a different key silently + # breaks the grouping contract. # # Subclasses may override (private): # finalize(state) → transforms finished state into the output hash From 52a6acb7ba514a4390f9d85f1938bae451cac60c Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Mon, 25 May 2026 12:52:46 -0400 Subject: [PATCH 16/41] Add shared state support to BaseReport and refactor TeammateReviewReport BaseReport#run now accepts an optional shared_state parameter so multiple aggregators can write into the same hash without a merge loop. When shared_state is provided, initial_state is ignored and finalize is skipped. TeammateReviewReport refactored into two aggregators sharing state keyed by user_id: TeammateReviewAggregator fills reviewer info, reviewees, and reviewed_count; TeamSizeAggregator fills teammate_count. Full state shape initialized upfront in Hash.new block for consistency. Co-Authored-By: Claude Sonnet 4.6 --- app/models/reports/base_report.rb | 11 +- app/models/reports/teammate_review_report.rb | 126 ++++++++++++++----- 2 files changed, 102 insertions(+), 35 deletions(-) diff --git a/app/models/reports/base_report.rb b/app/models/reports/base_report.rb index f444182e4..3cf5a413b 100644 --- a/app/models/reports/base_report.rb +++ b/app/models/reports/base_report.rb @@ -62,6 +62,11 @@ def initialize(reportable) # Runs the aggregator: stream → group → accumulate → finalize. # + # Accepts an optional shared_state so that multiple aggregators can write + # into the same hash without a merge loop. When shared_state is provided, + # initial_state is ignored and finalize is skipped — the coordinator owns + # the state lifecycle. When omitted, behaves as a standalone aggregator. + # # Benefits of this structure over writing report code directly: # 1. Memory safety — find_each streams in batches of 500 rather than # loading the entire relation into Ruby. Every report gets this for free. @@ -69,12 +74,12 @@ def initialize(reportable) # finalize; the aggregator wiring is not their concern. # 3. Single place for cross-cutting concerns — logging, timing, or error # handling can be added here once and applies to every report. - def run - state = initial_state + def run(shared_state = nil) + state = shared_state || initial_state source.find_each(batch_size: 500) do |row| accumulate(state, state_key_for.call(row), row) end - finalize(state) + shared_state ? state : finalize(state) end private diff --git a/app/models/reports/teammate_review_report.rb b/app/models/reports/teammate_review_report.rb index 6b68971c1..75d8fbcb4 100644 --- a/app/models/reports/teammate_review_report.rb +++ b/app/models/reports/teammate_review_report.rb @@ -1,48 +1,110 @@ # frozen_string_literal: true module Reports - # Teammate-review report: lists reviewers with all reviewees they evaluated - # for the assignment. + # Teammate-review report: for each reviewer, shows how many teammates they + # have in total, how many they reviewed, and the individual reviewee details. # - # Accumulator: groups TeammateReviewResponseMap rows by reviewer_id. On first - # occurrence, initializes the reviewer entry with an empty reviewees list. On - # every occurrence, appends the reviewee to that list. - # reviewer and reviewee associations are eagerly loaded to avoid N+1 queries. - class TeammateReviewReport < BaseReport - def source - TeammateReviewResponseMap - .where(reviewed_object_id: @reportable.id) - .includes(reviewer: :user, reviewee: :user) + # Coordinator runs two aggregators into a single shared state: + # + # TeammateReviewAggregator — streams TeammateReviewResponseMap rows. + # For each row, initializes the reviewer entry if absent and appends + # the reviewee to the reviewees list. + # Writes to state: { reviewer_id => { ..., reviewed_count:, reviewees: [] } } + # + # TeamSizeAggregator — streams TeamsUser rows for the assignment. + # For each row, sets teammate_count for that user if they appear in state + # (i.e. they are a reviewer). No extra loop needed — writes directly into + # the same shared state. + # Writes to state: { reviewer_id => { ..., teammate_count: } } + # + # Shared state shape (all keys present from initialization, keyed by user_id): + # { user_id => { reviewer_id:, user_id:, name:, full_name:, ← filled by TeammateReviewAggregator + # teammate_count:, ← filled by TeamSizeAggregator + # reviewed_count:, ← filled by TeammateReviewAggregator + # reviewees: [{ reviewee_id:, name:, full_name: }] } } + class TeammateReviewReport + def self.for_assignment(assignment) + new(assignment) end - def state_key_for = ->(map) { map.reviewer_id } + def initialize(reportable) + @reportable = reportable + end + + def run + shared_state = Hash.new do |state, user_id| + state[user_id] = { + reviewer_id: nil, + user_id: nil, + name: nil, + full_name: nil, + teammate_count: 0, + reviewed_count: 0, + reviewees: [] + } + end + TeammateReviewAggregator.new(@reportable).run(shared_state) + TeamSizeAggregator.new(@reportable).run(shared_state) + { reviewers: shared_state.values } + end - def initial_state = {} + # ----------------------------------------------------------------------- + # Aggregator 1 — per-reviewer reviewee details and reviewed count. + # Streams TeammateReviewResponseMap rows grouped by reviewer_id. + # ----------------------------------------------------------------------- + class TeammateReviewAggregator < BaseReport + def source + TeammateReviewResponseMap + .where(reviewed_object_id: @reportable.id) + .includes(reviewer: :user, reviewee: :user) + end - def accumulate(state, reviewer_id, map) - reviewer = map.reviewer - return unless reviewer + def state_key_for = ->(map) { map.reviewer&.user_id } - state[reviewer_id] ||= { - reviewer_id: reviewer.id, - user_id: reviewer.user_id, - name: reviewer.user&.name, - full_name: reviewer.user&.full_name, - reviewees: [] - } + def initial_state = {} - reviewee = map.reviewee - return unless reviewee + def accumulate(state, user_id, map) + reviewer = map.reviewer + return unless reviewer - state[reviewer_id][:reviewees] << { - reviewee_id: reviewee.id, - name: reviewee.user&.name, - full_name: reviewee.user&.full_name - } + unless state.key?(user_id) + state[user_id][:reviewer_id] = reviewer.id + state[user_id][:user_id] = reviewer.user_id + state[user_id][:name] = reviewer.user&.name + state[user_id][:full_name] = reviewer.user&.full_name + end + + reviewee = map.reviewee + return unless reviewee + + state[user_id][:reviewees] << { + reviewee_id: reviewee.id, + name: reviewee.user&.name, + full_name: reviewee.user&.full_name + } + state[user_id][:reviewed_count] += 1 + end end - def finalize(state) - { reviewers: state.values } + # ----------------------------------------------------------------------- + # Aggregator 2 — per-reviewer teammate count. + # Streams TeamsUser rows for the assignment. Only updates state for + # users who are already reviewers (present in shared state). + # ----------------------------------------------------------------------- + class TeamSizeAggregator < BaseReport + def source + TeamsUser.for_assignment(@reportable.id).includes(:user) + end + + def state_key_for = ->(teams_user) { teams_user.user_id } + + def initial_state = {} + + def accumulate(state, user_id, _teams_user) + # teammate_count is the number of TeamsUser rows for this user's team, + # which equals the number of teammates (including themselves). + state[user_id][:teammate_count] += 1 if state.key?(user_id) + end end end end From f27083306767d29463796855478c76d984a151a2 Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Mon, 25 May 2026 16:18:03 -0400 Subject: [PATCH 17/41] Refactor ReviewReport to use shared state and simplify score computation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge ReviewersReducer and ScoresReducer into a shared state keyed by reviewer_id so reviewer info and scores are co-located without a merge loop. ScoresReducer now delegates to calculate_total_score/maximum_score (ScorableHelper) with scores and questionnaires eagerly loaded to avoid N+1. AvgRangesReducer simplified to use aggregate_review_grade with full eager loading. Rename Aggregator → Reducer across all report files. Co-Authored-By: Claude Sonnet 4.6 --- app/models/reports/answer_tagging_report.rb | 40 ++--- app/models/reports/base_report.rb | 26 +-- app/models/reports/review_report.rb | 166 ++++++++------------ 3 files changed, 98 insertions(+), 134 deletions(-) diff --git a/app/models/reports/answer_tagging_report.rb b/app/models/reports/answer_tagging_report.rb index d2458b396..c74a5e5dc 100644 --- a/app/models/reports/answer_tagging_report.rb +++ b/app/models/reports/answer_tagging_report.rb @@ -31,15 +31,15 @@ def initialize(assignment) def run per_deployment = {} - # The coordinator runs one DeploymentAggregator per TagPromptDeployment, - # then passes the results to UserSummaryAggregator for cross-deployment totals. + # The coordinator runs one DeploymentReducer per TagPromptDeployment, + # then passes the results to UserSummaryReducer for cross-deployment totals. TagPromptDeployment .where(assignment_id: @reportable.id) .includes(:tag_prompt, :questionnaire) .each do |deployment| - per_deployment[deployment.id] = DeploymentAggregator.new(@reportable, deployment).run + per_deployment[deployment.id] = DeploymentReducer.new(@reportable, deployment).run end - user_summary = UserSummaryAggregator.new(per_deployment).run + user_summary = UserSummaryReducer.new(per_deployment).run { questionnaire_tagging_report: per_deployment, user_tagging_report: user_summary @@ -47,25 +47,25 @@ def run end # ------------------------------------------------------------------------- - # Coordinator — runs two streaming aggregators for one TagPromptDeployment + # Coordinator — runs two streaming reducers for one TagPromptDeployment # and merges their results: # - # TaggableAnswersAggregator — streams taggable Answer rows (joined with + # TaggableAnswersReducer — streams taggable Answer rows (joined with # response_maps, filtered by item type and threshold). Returns per-user # lists of taggable answer_ids. # Output: { user_id => [answer_ids] } # - # TaggingStatsAggregator — streams all AnswerTag rows for this deployment + # TaggingStatsReducer — streams all AnswerTag rows for this deployment # (joined with answers to get answer_id). No item/threshold filtering # in SQL — filtering happens in finalize by comparing answer_ids. # Output: { user_id => [{ answer_id:, response_id:, updated_at: }] } # - # Precomputed once, shared across both aggregators: + # Precomputed once, shared across both reducers: # @item_ids — IDs of items in the deployment's questionnaire (tiny) # @users_by_team — TeamsUser records grouped by team_id; also used in # finalize to build per-user name info # ------------------------------------------------------------------------- - class DeploymentAggregator + class DeploymentReducer def initialize(reportable, deployment) @reportable = reportable @deployment = deployment @@ -74,8 +74,8 @@ def initialize(reportable, deployment) end def run - taggable_data = TaggableAnswersAggregator.new(@reportable, @deployment, @item_ids, @users_by_team).run - tagging_stats = TaggingStatsAggregator.new(@reportable, @deployment).run + taggable_data = TaggableAnswersReducer.new(@reportable, @deployment, @item_ids, @users_by_team).run + tagging_stats = TaggingStatsReducer.new(@reportable, @deployment).run finalize(taggable_data, tagging_stats) end @@ -93,7 +93,7 @@ def finalize(taggable_data, tagging_stats) # Filter tags to only those on answers that are taggable for this user. # TODO: confirm with prof — if a reviewer submits multiple responses for the # same round, only the latest submitted response should be counted as taggable. - # TaggableAnswersAggregator may need to deduplicate by keeping only the most + # TaggableAnswersReducer may need to deduplicate by keeping only the most # recently submitted response per (reviewer, round). matching_tags = tagging_stats.fetch(user_id, []).select { |tag| taggable_answer_ids.include?(tag[:answer_id]) } cnt_tagged = matching_tags.size @@ -124,7 +124,7 @@ def finalize(taggable_data, tagging_stats) end # ----------------------------------------------------------------------- - # Aggregator 1 — per-user taggable answer IDs. + # Reducer 1 — per-user taggable answer IDs. # # Streams taggable Answer rows (joined with responses and response_maps, # filtered by item type and length threshold). Each row is one (answer, team) @@ -133,7 +133,7 @@ def finalize(taggable_data, tagging_stats) # # Output: { user_id => [answer_ids] } # ----------------------------------------------------------------------- - class TaggableAnswersAggregator < BaseReport + class TaggableAnswersReducer < BaseReport def initialize(reportable, deployment, item_ids, users_by_team) super(reportable) @deployment = deployment @@ -167,16 +167,16 @@ def accumulate(state, team_id, answer) end # ----------------------------------------------------------------------- - # Aggregator 2 — per-user answer tags with answer context. + # Reducer 2 — per-user answer tags with answer context. # # Streams all AnswerTag rows for this deployment (joined with answers). # No item or threshold filtering in SQL — the finalize step filters tags # by comparing their answer_id against the taggable answer_ids from - # Aggregator 1. + # Reducer 1. # # Output: { user_id => [{ answer_id:, response_id:, updated_at: }] } # ----------------------------------------------------------------------- - class TaggingStatsAggregator < BaseReport + class TaggingStatsReducer < BaseReport def initialize(reportable, deployment) super(reportable) @deployment = deployment @@ -202,10 +202,10 @@ def accumulate(state, user_id, tag) end # ------------------------------------------------------------------------- - # Aggregator 3 — cross-deployment per-user summary. - # Consumes DeploymentAggregator output; no additional DB queries. + # Reducer 3 — cross-deployment per-user summary. + # Consumes DeploymentReducer output; no additional DB queries. # ------------------------------------------------------------------------- - class UserSummaryAggregator + class UserSummaryReducer def initialize(per_deployment_result) @per_deployment = per_deployment_result end diff --git a/app/models/reports/base_report.rb b/app/models/reports/base_report.rb index 3cf5a413b..5d5e8ab41 100644 --- a/app/models/reports/base_report.rb +++ b/app/models/reports/base_report.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Reports - # Template for a streaming reduce-based report aggregator. + # Template for a streaming reduce-based report reducer. # # Design rationale (addresses two anti-patterns from the naive approach): # @@ -12,7 +12,7 @@ module Reports # # Anti-pattern 2 — "default metrics in base": encoding avg_score or any # domain metric in the base class ties every report to one shape of math. - # This class contains *only* the aggregator scaffold; each subclass owns its + # This class contains *only* the reducer scaffold; each subclass owns its # accumulate/finalize logic entirely. # # Subclasses must implement (private): @@ -23,12 +23,12 @@ module Reports # BaseReport#run calls state_key_for.call(row) and passes the # result as the key to accumulate — subclasses get this # wiring for free and can see at a glance what each - # aggregator is aggregating over. Examples: - # ScoresAggregator groups by reviewer_id — all responses + # reducer is aggregating over. Examples: + # ScoresReducer groups by reviewer_id — all responses # from the same reviewer go into the same bucket - # AvgRangesAggregator groups by reviewee_id — all responses + # AvgRangesReducer groups by reviewee_id — all responses # received by the same team go into the same bucket - # TaggableAnswersAggregator groups by team_id — all answers + # TaggableAnswersReducer groups by team_id — all answers # received by the same team go into the same bucket # initial_state → empty accumulator value # accumulate(state, key, row) → mutates state in place; key is the result @@ -55,23 +55,23 @@ def self.for_course(course) # @param reportable [Assignment, Course] the object the report is scoped to. # Subclasses reference @reportable instead of a type-specific variable so - # the same aggregator works for any reportable entity. + # the same reducer works for any reportable entity. def initialize(reportable) @reportable = reportable end - # Runs the aggregator: stream → group → accumulate → finalize. + # Runs the reducer: stream → group → accumulate → finalize. # - # Accepts an optional shared_state so that multiple aggregators can write + # Accepts an optional shared_state so that multiple reducers can write # into the same hash without a merge loop. When shared_state is provided, - # initial_state is ignored and finalize is skipped — the coordinator owns - # the state lifecycle. When omitted, behaves as a standalone aggregator. + # initial_state is ignored — the coordinator owns state initialization. + # finalize is always called # # Benefits of this structure over writing report code directly: # 1. Memory safety — find_each streams in batches of 500 rather than # loading the entire relation into Ruby. Every report gets this for free. # 2. New reports are just data — subclasses define source/state_key_for/accumulate/ - # finalize; the aggregator wiring is not their concern. + # finalize; the reducer wiring is not their concern. # 3. Single place for cross-cutting concerns — logging, timing, or error # handling can be added here once and applies to every report. def run(shared_state = nil) @@ -79,7 +79,7 @@ def run(shared_state = nil) source.find_each(batch_size: 500) do |row| accumulate(state, state_key_for.call(row), row) end - shared_state ? state : finalize(state) + finalize(state) end private diff --git a/app/models/reports/review_report.rb b/app/models/reports/review_report.rb index 7e56f379b..2720da4e6 100644 --- a/app/models/reports/review_report.rb +++ b/app/models/reports/review_report.rb @@ -1,15 +1,19 @@ # frozen_string_literal: true module Reports - # Peer-review report composed of three independent streaming aggregators: - # 1 ReviewersAggregator — distinct reviewers with user details - # 2 ScoresAggregator — per-reviewer × round × reviewee score pct - # 3 AvgRangesAggregator — per-team × round max / min / avg + # Peer-review report composed of three streaming reducers. # - # Each aggregator streams its own source relation via find_each, so no - # full result set is ever materialised in Ruby at once. - # max_question_score is precomputed once in #run and passed to both score - # aggregators to avoid a duplicate DB query and N+1 inside the accumulate loop. + # ReviewersReducer and ScoresReducer share a single state keyed by reviewer_id + # so reviewer info and scores are co-located without a merge loop: + # { reviewer_id => { id:, user_id:, name:, full_name:, handle:, + # scores: { reviewee_id => { round => score_pct } } } } + # + # AvgRangesReducer runs independently: + # { team_id => avg_score } + # + # Each reducer streams its source via find_each — no full result set is ever + # materialised in Ruby at once. Scores and questionnaires are eagerly loaded + # to avoid N+1 inside calculate_total_score and maximum_score (ScorableHelper). class ReviewReport def self.for_assignment(assignment) new(assignment) @@ -20,33 +24,43 @@ def initialize(reportable) end def run - # Precomputed once and passed to both score aggregators to avoid a duplicate DB query. - max_q_scores = AssignmentQuestionnaire - .for_assignment_and_type(@reportable.id, 'ReviewQuestionnaire') - .pluck(:used_in_round, 'questionnaires.max_question_score') - .to_h + shared_state = Hash.new do |state, reviewer_id| + state[reviewer_id] = { + id: nil, + user_id: nil, + name: nil, + full_name: nil, + handle: nil, + scores: Hash.new { |by_reviewee, reviewee_id| by_reviewee[reviewee_id] = {} } + } + end + + ReviewersReducer.new(@reportable).run(shared_state) + ScoresReducer.new(@reportable).run(shared_state) + { - reviewers: ReviewersAggregator.new(@reportable).run, - review_scores: ScoresAggregator.new(@reportable, max_q_scores).run, - avg_and_ranges: AvgRangesAggregator.new(@reportable, max_q_scores).run + reviewers: shared_state.values.sort_by { |r| r[:full_name].to_s.downcase }, + avg_and_ranges: AvgRangesReducer.new(@reportable).run } end - # Shared source for ScoresAggregator and AvgRangesAggregator. - # Both aggregators stream the same submitted ReviewResponseMap responses. + # Shared source for ScoresReducer. + # Streams submitted ReviewResponseMap responses with scores and questionnaires + # eagerly loaded to avoid N+1 inside calculate_total_score and maximum_score. module ReviewResponseShared def source Response .submitted_review_responses_for(@reportable.id) - .includes(:response_map, scores: :item) + .includes(:response_map, scores: { item: :questionnaire }) end end # ----------------------------------------------------------------------- - # Aggregator 1 — distinct reviewers sorted by full name. - # Groups by reviewer_id; first occurrence per reviewer is kept. + # Reducer 1 — reviewer info. + # Streams ReviewResponseMap rows; writes reviewer details into shared state + # on first occurrence per reviewer. # ----------------------------------------------------------------------- - class ReviewersAggregator < BaseReport + class ReviewersReducer < BaseReport def source ReviewResponseMap.for_assignment(@reportable.id).includes(reviewer: :user) end @@ -61,108 +75,58 @@ def accumulate(state, reviewer_id, response_map) reviewer = response_map.reviewer return unless reviewer - state[reviewer_id] = { - id: reviewer.id, - user_id: reviewer.user_id, - name: reviewer.user&.name, - full_name: reviewer.user&.full_name, - handle: reviewer.handle - } - end - - def finalize(state) - state.values.sort_by { |reviewer| reviewer[:full_name].to_s.downcase } + state[reviewer_id][:id] = reviewer.id + state[reviewer_id][:user_id] = reviewer.user_id + state[reviewer_id][:name] = reviewer.user&.name + state[reviewer_id][:full_name] = reviewer.user&.full_name + state[reviewer_id][:handle] = reviewer.handle end end # ----------------------------------------------------------------------- - # Aggregator 2 — per-reviewer × round × reviewee score percentages. - # Streams submitted Responses with scores and items eagerly loaded. - # Output: { reviewer_id => { round => { reviewee_id => pct } } } + # Reducer 2 — per-reviewer × reviewee × round score percentages. + # Streams submitted Responses; writes score_pct into shared state under + # state[reviewer_id][:scores][reviewee_id][round]. # ----------------------------------------------------------------------- - class ScoresAggregator < BaseReport + class ScoresReducer < BaseReport include ReviewResponseShared - def initialize(reportable, max_q_scores) - super(reportable) - @max_q_score = max_q_scores - end - def state_key_for = ->(response) { response.response_map.reviewer_id } - def initial_state - # Three-level nested hash: reviewer_id => round => reviewee_id => score_pct - # Each level auto-initializes when a missing key is accessed, - # so state[reviewer_id][round][reviewee_id] = pct never raises NoMethodError. - Hash.new do |by_reviewer, reviewer_id| - by_reviewer[reviewer_id] = Hash.new do |by_round, round| - by_round[round] = {} - end - end - end + def initial_state = {} def accumulate(state, reviewer_id, response) - answered = response.scores.reject { |s| s.answer.nil? } - total_wt = answered.sum { |s| s.item.weight } - return if total_wt.zero? + return if response.maximum_score.zero? - round = response.round || 1 reviewee_id = response.response_map.reviewee_id - raw_score = answered.sum { |s| s.answer * s.item.weight } - max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1) - return unless max_score.positive? + round = response.round || 1 + score_pct = (response.calculate_total_score.to_f / response.maximum_score * 100).round(2) - state[reviewer_id][round][reviewee_id] = ((raw_score.to_f / max_score) * 100).round(2) + state[reviewer_id][:scores][reviewee_id][round] = score_pct end end # ----------------------------------------------------------------------- - # Aggregator 3 — per-team × round aggregate (max / min / avg). - # Same response stream as Aggregator 2; groups by reviewee_id. - # Output: { team_id => { round => { max:, min:, avg: } } } + # Reducer 3 — per-team average review score. + # Streams AssignmentTeam rows with review_mappings, responses, and scores + # eagerly loaded to avoid N+1. Delegates score computation to + # aggregate_review_grade (via ReviewAggregator concern) which picks the + # latest submitted response per round per map and normalises the score. + # Output: { team_id => avg_score } # ----------------------------------------------------------------------- - # Check with prof how they are planning to use this information?? Is it for AVG score column? - class AvgRangesAggregator < BaseReport - include ReviewResponseShared - - def initialize(reportable, max_q_scores) - super(reportable) - @max_q_score = max_q_scores - end - - def state_key_for = ->(response) { response.response_map.reviewee_id } - - def initial_state - # Two-level nested hash: reviewee_id => round => [score_pcts] - # Each level auto-initializes when a missing key is accessed. - Hash.new do |by_team, reviewee_id| - by_team[reviewee_id] = Hash.new { |by_round, round| by_round[round] = [] } - end + class AvgRangesReducer < BaseReport + def source + AssignmentTeam + .where(parent_id: @reportable.id) + .includes(review_mappings: { responses: { scores: :item } }) end - def accumulate(state, reviewee_id, response) - answered = response.scores.reject { |s| s.answer.nil? } - total_wt = answered.sum { |s| s.item.weight } - return if total_wt.zero? + def state_key_for = ->(team) { team.id } - round = response.round || 1 - raw_score = answered.sum { |s| s.answer * s.item.weight } - max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1) - return unless max_score.positive? - - state[reviewee_id][round] << ((raw_score.to_f / max_score) * 100) - end + def initial_state = {} - def finalize(state) - state.transform_values do |rounds| - rounds.transform_values do |scores| - { - max: scores.max.round(2), - min: scores.min.round(2), - avg: (scores.sum / scores.size).round(2) - } - end - end + def accumulate(state, team_id, team) + state[team_id] = team.aggregate_review_grade end end end From 4871ec9f7d1ec6e8a3c645ec38a296fc23b62957 Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Mon, 25 May 2026 16:23:23 -0400 Subject: [PATCH 18/41] Fix stale comments in base_report and teammate_review_report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove redundant "reducer" from BaseReport class description - Fix AvgRangesReducer example: groups by team_id, not reviewee_id - Clarify finalize-always-called sentence in BaseReport#run - Fix teammate_review_report state key comments: reviewer_id → user_id - Rename leftover Aggregator → Reducer in teammate_review_report comments and class names Co-Authored-By: Claude Sonnet 4.6 --- app/models/reports/base_report.rb | 8 +++--- app/models/reports/teammate_review_report.rb | 28 ++++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/app/models/reports/base_report.rb b/app/models/reports/base_report.rb index 5d5e8ab41..d11718cc5 100644 --- a/app/models/reports/base_report.rb +++ b/app/models/reports/base_report.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Reports - # Template for a streaming reduce-based report reducer. + # Template for a streaming reduce-based report. # # Design rationale (addresses two anti-patterns from the naive approach): # @@ -26,8 +26,8 @@ module Reports # reducer is aggregating over. Examples: # ScoresReducer groups by reviewer_id — all responses # from the same reviewer go into the same bucket - # AvgRangesReducer groups by reviewee_id — all responses - # received by the same team go into the same bucket + # AvgRangesReducer groups by team_id — all review mappings + # for the same team go into the same bucket # TaggableAnswersReducer groups by team_id — all answers # received by the same team go into the same bucket # initial_state → empty accumulator value @@ -65,7 +65,7 @@ def initialize(reportable) # Accepts an optional shared_state so that multiple reducers can write # into the same hash without a merge loop. When shared_state is provided, # initial_state is ignored — the coordinator owns state initialization. - # finalize is always called + # finalize is always called, even when shared_state is provided. # # Benefits of this structure over writing report code directly: # 1. Memory safety — find_each streams in batches of 500 rather than diff --git a/app/models/reports/teammate_review_report.rb b/app/models/reports/teammate_review_report.rb index 75d8fbcb4..ebdba29c9 100644 --- a/app/models/reports/teammate_review_report.rb +++ b/app/models/reports/teammate_review_report.rb @@ -4,23 +4,23 @@ module Reports # Teammate-review report: for each reviewer, shows how many teammates they # have in total, how many they reviewed, and the individual reviewee details. # - # Coordinator runs two aggregators into a single shared state: + # Coordinator runs two reducers into a single shared state: # - # TeammateReviewAggregator — streams TeammateReviewResponseMap rows. + # TeammateReviewReducer — streams TeammateReviewResponseMap rows. # For each row, initializes the reviewer entry if absent and appends # the reviewee to the reviewees list. - # Writes to state: { reviewer_id => { ..., reviewed_count:, reviewees: [] } } + # Writes to state: { user_id => { ..., reviewed_count:, reviewees: [] } } # - # TeamSizeAggregator — streams TeamsUser rows for the assignment. + # TeamSizeReducer — streams TeamsUser rows for the assignment. # For each row, sets teammate_count for that user if they appear in state # (i.e. they are a reviewer). No extra loop needed — writes directly into # the same shared state. - # Writes to state: { reviewer_id => { ..., teammate_count: } } + # Writes to state: { user_id => { ..., teammate_count: } } # # Shared state shape (all keys present from initialization, keyed by user_id): - # { user_id => { reviewer_id:, user_id:, name:, full_name:, ← filled by TeammateReviewAggregator - # teammate_count:, ← filled by TeamSizeAggregator - # reviewed_count:, ← filled by TeammateReviewAggregator + # { user_id => { reviewer_id:, user_id:, name:, full_name:, ← filled by TeammateReviewReducer + # teammate_count:, ← filled by TeamSizeReducer + # reviewed_count:, ← filled by TeammateReviewReducer # reviewees: [{ reviewee_id:, name:, full_name: }] } } class TeammateReviewReport def self.for_assignment(assignment) @@ -43,16 +43,16 @@ def run reviewees: [] } end - TeammateReviewAggregator.new(@reportable).run(shared_state) - TeamSizeAggregator.new(@reportable).run(shared_state) + TeammateReviewReducer.new(@reportable).run(shared_state) + TeamSizeReducer.new(@reportable).run(shared_state) { reviewers: shared_state.values } end # ----------------------------------------------------------------------- - # Aggregator 1 — per-reviewer reviewee details and reviewed count. + # Reducer 1 — per-reviewer reviewee details and reviewed count. # Streams TeammateReviewResponseMap rows grouped by reviewer_id. # ----------------------------------------------------------------------- - class TeammateReviewAggregator < BaseReport + class TeammateReviewReducer < BaseReport def source TeammateReviewResponseMap .where(reviewed_object_id: @reportable.id) @@ -87,11 +87,11 @@ def accumulate(state, user_id, map) end # ----------------------------------------------------------------------- - # Aggregator 2 — per-reviewer teammate count. + # Reducer 2 — per-reviewer teammate count. # Streams TeamsUser rows for the assignment. Only updates state for # users who are already reviewers (present in shared state). # ----------------------------------------------------------------------- - class TeamSizeAggregator < BaseReport + class TeamSizeReducer < BaseReport def source TeamsUser.for_assignment(@reportable.id).includes(:user) end From ec2614a587f86aef87c74fbd48ffef995b83ea70 Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Mon, 25 May 2026 16:25:35 -0400 Subject: [PATCH 19/41] Add answer_tagging report and use for_assignment factory in ReportsController - Register AnswerTaggingReport under the 'answer_tagging' type key - Switch from report_class.new(assignment) to report_class.for_assignment(assignment) to match the factory method convention used by all report classes Co-Authored-By: Claude Sonnet 4.6 --- app/controllers/reports_controller.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index 6c92a9c14..f1bc67fd2 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -6,6 +6,7 @@ class ReportsController < ApplicationController 'feedback_response_map' => Reports::FeedbackReport, 'teammate_review_response_map' => Reports::TeammateReviewReport, 'bookmark_rating_response_map' => Reports::BookmarkRatingReport, + 'answer_tagging' => Reports::AnswerTaggingReport, 'basic' => Reports::BasicReport }.freeze @@ -28,7 +29,7 @@ def response_report end assignment = Assignment.find(assignment_id) - data = report_class.new(assignment).run + data = report_class.for_assignment(assignment).run render json: { type: type, assignment_id: assignment_id.to_i }.merge(data) rescue ActiveRecord::RecordNotFound render json: { error: 'Assignment not found' }, status: :not_found From 0e5a0618fb33177fcf1047ee88a381e6562982b5 Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Mon, 25 May 2026 16:38:51 -0400 Subject: [PATCH 20/41] Simplify ReportsController: POST-only, rename to fetch_response_report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove GET route; keep only POST /reports/fetch_response_report - Rename action from response_report to fetch_response_report - Drop params[:id] fallback and params.dig(:report, :type) nesting — JSON API clients send flat { assignment_id:, type: } body Co-Authored-By: Claude Sonnet 4.6 --- app/controllers/reports_controller.rb | 8 ++++---- config/routes.rb | 6 ++++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index f1bc67fd2..b4171d5eb 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -15,11 +15,11 @@ def action_allowed? current_user_has_ta_privileges? end - # GET/POST /reports/response_report?assignment_id=&type= + # POST /reports/fetch_response_report # Returns the requested report as JSON. - def response_report - assignment_id = params[:assignment_id] || params[:id] - type = params.dig(:report, :type) || params[:type] || 'basic' + def fetch_response_report + assignment_id = params[:assignment_id] + type = params[:type] || 'basic' report_class = REPORT_CLASSES[type] unless report_class diff --git a/config/routes.rb b/config/routes.rb index 57559d007..62d0cce2a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -195,6 +195,12 @@ delete :delete_participants end end + resources :reports, only: [] do + collection do + post :fetch_response_report + end + end + resources :grades do collection do get '/:assignment_id/view_all_scores', to: 'grades#view_all_scores' From bbeb1f15c077dfcd867f2af92d2f5ed1ab2ab2bb Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Mon, 25 May 2026 17:03:01 -0400 Subject: [PATCH 21/41] Add submit and unsubmit member routes for responses Co-Authored-By: Claude Sonnet 4.6 --- config/routes.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config/routes.rb b/config/routes.rb index a39f35693..1b81e5bbb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -212,6 +212,12 @@ get '/:participant_id/instructor_review', to: 'grades#instructor_review' end end + resources :responses do + member do + patch :submit # PATCH /responses/:id/submit + patch :unsubmit # PATCH /responses/:id/unsubmit + end + end resources :duties do collection do get :accessible_duties From acdbeb3aebe8fdcfdaac344f6e0e209529d8b8e5 Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Mon, 25 May 2026 17:15:22 -0400 Subject: [PATCH 22/41] Remove trailing whitespace in routes.rb Co-Authored-By: Claude Sonnet 4.6 --- config/routes.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/config/routes.rb b/config/routes.rb index 1b81e5bbb..a5c0e2b65 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -76,8 +76,7 @@ delete 'delete_all/questionnaire/:id', to:'questions#delete_all#questionnaire', as: 'delete_all' end end - - + resources :review_mappings, only: [] do collection do post :assign_round_robin From c4274fcf5afc5a8e20723ee475bdfc6180326d9b Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Mon, 25 May 2026 18:11:20 -0400 Subject: [PATCH 23/41] Removing other parts not needed for review report --- app/helpers/report_formatter_helper.rb | 7 - app/models/Item.rb | 2 - app/models/answer.rb | 13 -- app/models/answer_tag.rb | 14 -- app/models/assignment_questionnaire.rb | 4 - app/models/reports/answer_tagging_report.rb | 233 ------------------- app/models/reports/bookmark_rating_report.rb | 28 --- app/models/reports/feedback_report.rb | 86 ------- app/models/reports/teammate_review_report.rb | 110 --------- app/models/review_response_map.rb | 8 - app/models/teams_user.rb | 2 - app/services/reports/feedback_report.rb | 86 ------- 12 files changed, 593 deletions(-) delete mode 100644 app/helpers/report_formatter_helper.rb delete mode 100644 app/models/answer_tag.rb delete mode 100644 app/models/reports/answer_tagging_report.rb delete mode 100644 app/models/reports/bookmark_rating_report.rb delete mode 100644 app/models/reports/feedback_report.rb delete mode 100644 app/models/reports/teammate_review_report.rb delete mode 100644 app/services/reports/feedback_report.rb diff --git a/app/helpers/report_formatter_helper.rb b/app/helpers/report_formatter_helper.rb deleted file mode 100644 index 6297c0fca..000000000 --- a/app/helpers/report_formatter_helper.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -# Report logic has been moved to app/services/reports/. -# This module is retained as a namespace placeholder for any future -# view-layer formatting helpers. -module ReportFormatterHelper -end diff --git a/app/models/Item.rb b/app/models/Item.rb index a908656ef..807bcf06a 100644 --- a/app/models/Item.rb +++ b/app/models/Item.rb @@ -6,8 +6,6 @@ class Item < ApplicationRecord has_many :answers, dependent: :destroy, foreign_key: 'item_id' attr_accessor :choice_strategy - scope :for_questionnaire_and_type, ->(questionnaire_id, question_type) { where(questionnaire_id: questionnaire_id, question_type: question_type) } - validates :seq, presence: true, numericality: true # sequence must be numeric validates :txt, length: { minimum: 0, allow_nil: false, message: "can't be nil" } # text content must be provided validates :question_type, presence: true # user must define the item type diff --git a/app/models/answer.rb b/app/models/answer.rb index a62268d94..3c3320afe 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -3,17 +3,4 @@ class Answer < ApplicationRecord belongs_to :response belongs_to :item - - scope :for_items_and_responses, ->(item_ids, response_ids) { where(item_id: item_ids, response_id: response_ids) } - - scope :taggable_for_assignment, ->(assignment_id, item_ids, type:, threshold: nil) { - scope = joins(response: :response_map) - .where( - item_id: item_ids, - responses: { is_submitted: true }, - response_maps: { reviewed_object_id: assignment_id, type: type } - ) - scope = scope.where('LENGTH(answers.comments) > ?', threshold) if threshold - scope - } end diff --git a/app/models/answer_tag.rb b/app/models/answer_tag.rb deleted file mode 100644 index 003649411..000000000 --- a/app/models/answer_tag.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -class AnswerTag < ApplicationRecord - belongs_to :answer - belongs_to :tag_prompt_deployment - belongs_to :user - - scope :for_deployment, ->(deployment_id) { where(tag_prompt_deployment_id: deployment_id) } - - validates :answer_id, presence: true - validates :tag_prompt_deployment_id, presence: true - validates :user_id, presence: true - validates :value, presence: true -end diff --git a/app/models/assignment_questionnaire.rb b/app/models/assignment_questionnaire.rb index 3efa4e9bc..87a55883d 100644 --- a/app/models/assignment_questionnaire.rb +++ b/app/models/assignment_questionnaire.rb @@ -3,8 +3,4 @@ class AssignmentQuestionnaire < ApplicationRecord belongs_to :assignment belongs_to :questionnaire - - scope :for_assignment_and_type, ->(assignment_id, questionnaire_type) { - joins(:questionnaire).where(assignment_id: assignment_id, questionnaires: { questionnaire_type: questionnaire_type }) - } end diff --git a/app/models/reports/answer_tagging_report.rb b/app/models/reports/answer_tagging_report.rb deleted file mode 100644 index c74a5e5dc..000000000 --- a/app/models/reports/answer_tagging_report.rb +++ /dev/null @@ -1,233 +0,0 @@ -# frozen_string_literal: true - -module Reports - # Answer-tagging report: shows per-user tagging progress for each - # TagPromptDeployment on the assignment, plus a cross-deployment summary. - # - # Output shape: - # { - # questionnaire_tagging_report: { - # => { - # questionnaire_name:, prompt:, question_type:, - # answer_length_threshold:, - # user_stats: [{ user_id:, name:, full_name:, percentage:, - # cnt_tagged:, cnt_not_tagged:, cnt_taggable:, - # tag_update_intervals: }] - # } - # }, - # user_tagging_report: { - # => { user_id:, name:, full_name:, percentage:, - # cnt_tagged:, cnt_not_tagged:, cnt_taggable: } - # } - # } - class AnswerTaggingReport - def self.for_assignment(assignment) - new(assignment) - end - - def initialize(assignment) - @reportable = assignment - end - - def run - per_deployment = {} - # The coordinator runs one DeploymentReducer per TagPromptDeployment, - # then passes the results to UserSummaryReducer for cross-deployment totals. - TagPromptDeployment - .where(assignment_id: @reportable.id) - .includes(:tag_prompt, :questionnaire) - .each do |deployment| - per_deployment[deployment.id] = DeploymentReducer.new(@reportable, deployment).run - end - user_summary = UserSummaryReducer.new(per_deployment).run - { - questionnaire_tagging_report: per_deployment, - user_tagging_report: user_summary - } - end - - # ------------------------------------------------------------------------- - # Coordinator — runs two streaming reducers for one TagPromptDeployment - # and merges their results: - # - # TaggableAnswersReducer — streams taggable Answer rows (joined with - # response_maps, filtered by item type and threshold). Returns per-user - # lists of taggable answer_ids. - # Output: { user_id => [answer_ids] } - # - # TaggingStatsReducer — streams all AnswerTag rows for this deployment - # (joined with answers to get answer_id). No item/threshold filtering - # in SQL — filtering happens in finalize by comparing answer_ids. - # Output: { user_id => [{ answer_id:, response_id:, updated_at: }] } - # - # Precomputed once, shared across both reducers: - # @item_ids — IDs of items in the deployment's questionnaire (tiny) - # @users_by_team — TeamsUser records grouped by team_id; also used in - # finalize to build per-user name info - # ------------------------------------------------------------------------- - class DeploymentReducer - def initialize(reportable, deployment) - @reportable = reportable - @deployment = deployment - @item_ids = Item.for_questionnaire_and_type(deployment.questionnaire_id, deployment.question_type).pluck(:id) - @users_by_team = TeamsUser.for_assignment(deployment.assignment_id).includes(:user).group_by(&:team_id) - end - - def run - taggable_data = TaggableAnswersReducer.new(@reportable, @deployment, @item_ids, @users_by_team).run - tagging_stats = TaggingStatsReducer.new(@reportable, @deployment).run - finalize(taggable_data, tagging_stats) - end - - private - - def finalize(taggable_data, tagging_stats) - user_info = @users_by_team.values.flatten.each_with_object({}) do |teams_user, info| - info[teams_user.user_id] = { name: teams_user.user.name, full_name: teams_user.user.full_name } - end - - user_stats = user_info.map do |user_id, info| - taggable_answer_ids = taggable_data.fetch(user_id, []).to_set - cnt_taggable = taggable_answer_ids.size - - # Filter tags to only those on answers that are taggable for this user. - # TODO: confirm with prof — if a reviewer submits multiple responses for the - # same round, only the latest submitted response should be counted as taggable. - # TaggableAnswersReducer may need to deduplicate by keeping only the most - # recently submitted response per (reviewer, round). - matching_tags = tagging_stats.fetch(user_id, []).select { |tag| taggable_answer_ids.include?(tag[:answer_id]) } - cnt_tagged = matching_tags.size - cnt_not_tagged = cnt_taggable - cnt_tagged - - tag_times_in_order = matching_tags.map { |tag| tag[:updated_at] }.sort - intervals_between_tags = tag_times_in_order.each_cons(2).map { |earlier, later| later - earlier } - - { - user_id: user_id, - name: info[:name], - full_name: info[:full_name], - percentage: cnt_taggable.zero? ? '0.0' : format('%.1f', cnt_tagged.to_f / cnt_taggable * 100), - cnt_tagged: cnt_tagged, - cnt_not_tagged: cnt_not_tagged, - cnt_taggable: cnt_taggable, - tag_update_intervals: intervals_between_tags - } - end - - { - questionnaire_name: @deployment.questionnaire.name, - prompt: @deployment.tag_prompt.prompt, - question_type: @deployment.question_type, - answer_length_threshold: @deployment.answer_length_threshold, - user_stats: user_stats - } - end - - # ----------------------------------------------------------------------- - # Reducer 1 — per-user taggable answer IDs. - # - # Streams taggable Answer rows (joined with responses and response_maps, - # filtered by item type and length threshold). Each row is one (answer, team) - # pair — answers.id is unique per row so find_each paginates correctly. - # For each row, adds the answer_id to all users of the team. - # - # Output: { user_id => [answer_ids] } - # ----------------------------------------------------------------------- - class TaggableAnswersReducer < BaseReport - def initialize(reportable, deployment, item_ids, users_by_team) - super(reportable) - @deployment = deployment - @item_ids = item_ids - @users_by_team = users_by_team - end - - def source - return Answer.none if @item_ids.empty? - - Answer - .taggable_for_assignment( - @deployment.assignment_id, @item_ids, - type: 'ReviewResponseMap', - threshold: @deployment.answer_length_threshold - ) - .select('answers.id, response_maps.reviewee_id as team_id') - end - - def state_key_for = ->(answer) { answer.team_id } - - def initial_state - Hash.new { |state, user_id| state[user_id] = [] } - end - - def accumulate(state, team_id, answer) - (@users_by_team[team_id] || []).each do |teams_user| - state[teams_user.user_id] << answer.id - end - end - end - - # ----------------------------------------------------------------------- - # Reducer 2 — per-user answer tags with answer context. - # - # Streams all AnswerTag rows for this deployment (joined with answers). - # No item or threshold filtering in SQL — the finalize step filters tags - # by comparing their answer_id against the taggable answer_ids from - # Reducer 1. - # - # Output: { user_id => [{ answer_id:, response_id:, updated_at: }] } - # ----------------------------------------------------------------------- - class TaggingStatsReducer < BaseReport - def initialize(reportable, deployment) - super(reportable) - @deployment = deployment - end - - def source - AnswerTag - .for_deployment(@deployment.id) - .joins(:answer) - .select('answer_tags.id, answer_tags.user_id, answer_tags.answer_id, answer_tags.updated_at, answers.response_id') - end - - def state_key_for = ->(tag) { tag.user_id } - - def initial_state - Hash.new { |state, user_id| state[user_id] = [] } - end - - def accumulate(state, user_id, tag) - state[user_id] << { answer_id: tag.answer_id, response_id: tag.response_id, updated_at: tag.updated_at } - end - end - end - - # ------------------------------------------------------------------------- - # Reducer 3 — cross-deployment per-user summary. - # Consumes DeploymentReducer output; no additional DB queries. - # ------------------------------------------------------------------------- - class UserSummaryReducer - def initialize(per_deployment_result) - @per_deployment = per_deployment_result - end - - def run - summary = {} - @per_deployment.each_value do |deployment_data| - deployment_data[:user_stats].each do |stat| - key = stat[:user_id] - if summary.key?(key) - entry = summary[key] - entry[:cnt_tagged] += stat[:cnt_tagged] - entry[:cnt_not_tagged] += stat[:cnt_not_tagged] - entry[:cnt_taggable] += stat[:cnt_taggable] - entry[:percentage] = entry[:cnt_taggable].zero? ? '-' : format('%.1f', entry[:cnt_tagged].to_f / entry[:cnt_taggable] * 100) - else - summary[key] = stat.slice(:user_id, :name, :full_name, :cnt_tagged, :cnt_not_tagged, :cnt_taggable, :percentage) - end - end - end - summary - end - end - end -end diff --git a/app/models/reports/bookmark_rating_report.rb b/app/models/reports/bookmark_rating_report.rb deleted file mode 100644 index a6cdc95e9..000000000 --- a/app/models/reports/bookmark_rating_report.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module Reports - # Bookmark-rating report: which bookmarks were rated under this assignment - # and the associated project topics. - # - # Accumulator: groups BookmarkRatingResponseMap rows by reviewee_id (the - # bookmark being rated) and collects distinct bookmark IDs in one pass. - # Project topics are fetched separately (one query) since they are not - # streamed per row. - class BookmarkRatingReport < BaseReport - def source - BookmarkRatingResponseMap.where(reviewed_object_id: @reportable.id) - end - - def state_key_for = ->(map) { map.reviewee_id } - def initial_state = Set.new - - def accumulate(state, bookmark_id, _map) - state.add(bookmark_id) - end - - def finalize(bookmark_ids) - topics = @reportable.project_topics.map { |t| { id: t.id, topic_name: t.topic_name } } - { bookmark_ids: bookmark_ids.to_a, topics: topics } - end - end -end diff --git a/app/models/reports/feedback_report.rb b/app/models/reports/feedback_report.rb deleted file mode 100644 index 122a07303..000000000 --- a/app/models/reports/feedback_report.rb +++ /dev/null @@ -1,86 +0,0 @@ -# frozen_string_literal: true - -module Reports - # Author-feedback report: shows the latest review response IDs that received - # author feedback, bucketed by round for varying-rubric assignments. - # - # Pipeline: - # source — Responses on ReviewResponseMaps for this assignment, - # ordered newest-first so the first occurrence per (map, round) - # is the latest revision. - # state_key_for — [map_id, round]: deduplication key (one response per map per round) - # accumulate — skips duplicates; buckets response IDs by round - # finalize — fetches authors (one query), returns shaped hash - class FeedbackReport < BaseReport - def source - Response - .joins(:response_map) - .where(response_maps: { type: 'ReviewResponseMap', reviewed_object_id: @reportable.id }) - .order(created_at: :desc) - end - - def state_key_for = ->(r) { [r.map_id, r.round] } - - def initial_state - { seen: Set.new, round_1: [], round_2: [], round_3: [], all: [] } - end - - def accumulate(state, key, response) - return if state[:seen].include?(key) - - state[:seen].add(key) - - if @reportable.varying_rubrics_by_round? - case response.round - when 1 then state[:round_1] << response.id - when 2 then state[:round_2] << response.id - when 3 then state[:round_3] << response.id - end - else - state[:all] << response.id - end - end - - def finalize(state) - authors = fetch_authors - - if @reportable.varying_rubrics_by_round? - { - authors: authors.map { |a| format_participant(a) }, - review_response_ids: { - round_1: state[:round_1], - round_2: state[:round_2], - round_3: state[:round_3] - } - } - else - { - authors: authors.map { |a| format_participant(a) }, - review_response_ids: state[:all] - } - end - end - - private - - def fetch_authors - teams = AssignmentTeam.includes(:users).where(parent_id: @reportable.id) - teams.flat_map do |team| - team.users.filter_map do |user| - AssignmentParticipant.find_by(parent_id: @reportable.id, user_id: user.id) - end - end - end - - def format_participant(p) - return {} unless p - - { - id: p.id, - user_id: p.user_id, - name: p.user&.name, - full_name: p.user&.full_name - } - end - end -end diff --git a/app/models/reports/teammate_review_report.rb b/app/models/reports/teammate_review_report.rb deleted file mode 100644 index ebdba29c9..000000000 --- a/app/models/reports/teammate_review_report.rb +++ /dev/null @@ -1,110 +0,0 @@ -# frozen_string_literal: true - -module Reports - # Teammate-review report: for each reviewer, shows how many teammates they - # have in total, how many they reviewed, and the individual reviewee details. - # - # Coordinator runs two reducers into a single shared state: - # - # TeammateReviewReducer — streams TeammateReviewResponseMap rows. - # For each row, initializes the reviewer entry if absent and appends - # the reviewee to the reviewees list. - # Writes to state: { user_id => { ..., reviewed_count:, reviewees: [] } } - # - # TeamSizeReducer — streams TeamsUser rows for the assignment. - # For each row, sets teammate_count for that user if they appear in state - # (i.e. they are a reviewer). No extra loop needed — writes directly into - # the same shared state. - # Writes to state: { user_id => { ..., teammate_count: } } - # - # Shared state shape (all keys present from initialization, keyed by user_id): - # { user_id => { reviewer_id:, user_id:, name:, full_name:, ← filled by TeammateReviewReducer - # teammate_count:, ← filled by TeamSizeReducer - # reviewed_count:, ← filled by TeammateReviewReducer - # reviewees: [{ reviewee_id:, name:, full_name: }] } } - class TeammateReviewReport - def self.for_assignment(assignment) - new(assignment) - end - - def initialize(reportable) - @reportable = reportable - end - - def run - shared_state = Hash.new do |state, user_id| - state[user_id] = { - reviewer_id: nil, - user_id: nil, - name: nil, - full_name: nil, - teammate_count: 0, - reviewed_count: 0, - reviewees: [] - } - end - TeammateReviewReducer.new(@reportable).run(shared_state) - TeamSizeReducer.new(@reportable).run(shared_state) - { reviewers: shared_state.values } - end - - # ----------------------------------------------------------------------- - # Reducer 1 — per-reviewer reviewee details and reviewed count. - # Streams TeammateReviewResponseMap rows grouped by reviewer_id. - # ----------------------------------------------------------------------- - class TeammateReviewReducer < BaseReport - def source - TeammateReviewResponseMap - .where(reviewed_object_id: @reportable.id) - .includes(reviewer: :user, reviewee: :user) - end - - def state_key_for = ->(map) { map.reviewer&.user_id } - - def initial_state = {} - - def accumulate(state, user_id, map) - reviewer = map.reviewer - return unless reviewer - - unless state.key?(user_id) - state[user_id][:reviewer_id] = reviewer.id - state[user_id][:user_id] = reviewer.user_id - state[user_id][:name] = reviewer.user&.name - state[user_id][:full_name] = reviewer.user&.full_name - end - - reviewee = map.reviewee - return unless reviewee - - state[user_id][:reviewees] << { - reviewee_id: reviewee.id, - name: reviewee.user&.name, - full_name: reviewee.user&.full_name - } - state[user_id][:reviewed_count] += 1 - end - end - - # ----------------------------------------------------------------------- - # Reducer 2 — per-reviewer teammate count. - # Streams TeamsUser rows for the assignment. Only updates state for - # users who are already reviewers (present in shared state). - # ----------------------------------------------------------------------- - class TeamSizeReducer < BaseReport - def source - TeamsUser.for_assignment(@reportable.id).includes(:user) - end - - def state_key_for = ->(teams_user) { teams_user.user_id } - - def initial_state = {} - - def accumulate(state, user_id, _teams_user) - # teammate_count is the number of TeamsUser rows for this user's team, - # which equals the number of teammates (including themselves). - state[user_id][:teammate_count] += 1 if state.key?(user_id) - end - end - end -end diff --git a/app/models/review_response_map.rb b/app/models/review_response_map.rb index 0c8bfa7ca..0a8e02697 100644 --- a/app/models/review_response_map.rb +++ b/app/models/review_response_map.rb @@ -27,12 +27,4 @@ def get_title def review_map_type 'ReviewResponseMap' end - - # Returns an array of distinct reviewers (AssignmentParticipant objects) for the given assignment. - # Reviewers are sorted by their user's full name. - def self.review_response_report(assignment_id) - distinct_reviewer_ids = where(reviewed_object_id: assignment_id).distinct.pluck(:reviewer_id) - reviewers = AssignmentParticipant.where(id: distinct_reviewer_ids, parent_id: assignment_id) - reviewers.sort_by { |r| r.user&.full_name.to_s.downcase } - end end diff --git a/app/models/teams_user.rb b/app/models/teams_user.rb index 8e82c704f..8afd23cce 100644 --- a/app/models/teams_user.rb +++ b/app/models/teams_user.rb @@ -4,8 +4,6 @@ class TeamsUser < ApplicationRecord belongs_to :user belongs_to :team - scope :for_assignment, ->(assignment_id) { joins(:team).where(teams: { parent_id: assignment_id }) } - def name(ip_address = nil) name = user.name(ip_address) end diff --git a/app/services/reports/feedback_report.rb b/app/services/reports/feedback_report.rb deleted file mode 100644 index 9da2e4709..000000000 --- a/app/services/reports/feedback_report.rb +++ /dev/null @@ -1,86 +0,0 @@ -# frozen_string_literal: true - -module Reports - # Author-feedback report: shows the latest review response IDs that received - # author feedback, bucketed by round for varying-rubric assignments. - # - # Pipeline: - # source — Responses on ReviewResponseMaps for this assignment, - # ordered newest-first so the first occurrence per (map, round) - # is the latest revision. - # grouper — [map_id, round]: deduplication key (one response per map per round) - # accumulate — skips duplicates; buckets response IDs by round - # finalize — fetches authors (one query), returns shaped hash - class FeedbackReport < BaseReport - def source - Response - .joins(:response_map) - .where(response_maps: { type: 'ReviewResponseMap', reviewed_object_id: @assignment.id }) - .order(created_at: :desc) - end - - def grouper = ->(r) { [r.map_id, r.round] } - - def initial_state - { seen: Set.new, round_1: [], round_2: [], round_3: [], all: [] } - end - - def accumulate(state, key, response) - return if state[:seen].include?(key) - - state[:seen].add(key) - - if @assignment.varying_rubrics_by_round? - case response.round - when 1 then state[:round_1] << response.id - when 2 then state[:round_2] << response.id - when 3 then state[:round_3] << response.id - end - else - state[:all] << response.id - end - end - - def finalize(state) - authors = fetch_authors - - if @assignment.varying_rubrics_by_round? - { - authors: authors.map { |a| format_participant(a) }, - review_response_ids: { - round_1: state[:round_1], - round_2: state[:round_2], - round_3: state[:round_3] - } - } - else - { - authors: authors.map { |a| format_participant(a) }, - review_response_ids: state[:all] - } - end - end - - private - - def fetch_authors - teams = AssignmentTeam.includes(:users).where(parent_id: @assignment.id) - teams.flat_map do |team| - team.users.filter_map do |user| - AssignmentParticipant.find_by(parent_id: @assignment.id, user_id: user.id) - end - end - end - - def format_participant(p) - return {} unless p - - { - id: p.id, - user_id: p.user_id, - name: p.user&.name, - full_name: p.user&.full_name - } - end - end -end From 2c1bfd814e0fd8836744e47792dc3bbf12b89b0d Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Tue, 26 May 2026 15:39:55 -0400 Subject: [PATCH 24/41] Remove state_key_for from BaseReport and all reducers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit state_key_for was not enforced — accumulate could use any key it wanted — making it documentation-only with misleading precision. Removed it entirely: - BaseReport#run now calls accumulate(state, row); key derivation lives in accumulate - ReviewersReducer, ScoresReducer, AvgRangesReducer each derive their own key locally Co-Authored-By: Claude Sonnet 4.6 --- app/models/reports/base_report.rb | 37 +++++++---------------------- app/models/reports/review_report.rb | 16 +++++-------- 2 files changed, 15 insertions(+), 38 deletions(-) diff --git a/app/models/reports/base_report.rb b/app/models/reports/base_report.rb index d11718cc5..8bad5a45f 100644 --- a/app/models/reports/base_report.rb +++ b/app/models/reports/base_report.rb @@ -16,28 +16,10 @@ module Reports # accumulate/finalize logic entirely. # # Subclasses must implement (private): - # source → AR relation (consumed via find_each) - # state_key_for → lambda(row) → state bucket key - # Separates "what bucket does this row belong to?" from - # "what do I do with a row in that bucket?" (accumulate). - # BaseReport#run calls state_key_for.call(row) and passes the - # result as the key to accumulate — subclasses get this - # wiring for free and can see at a glance what each - # reducer is aggregating over. Examples: - # ScoresReducer groups by reviewer_id — all responses - # from the same reviewer go into the same bucket - # AvgRangesReducer groups by team_id — all review mappings - # for the same team go into the same bucket - # TaggableAnswersReducer groups by team_id — all answers - # received by the same team go into the same bucket - # initial_state → empty accumulator value - # accumulate(state, key, row) → mutates state in place; key is the result - # of state_key_for.call(row). Answers "what do I do with a row - # in this bucket?" — all domain math lives here, not in - # the base class. Note: BaseReport passes the key but does not - # enforce that accumulate uses it — subclasses are trusted to - # use it as the state bucket key; using a different key silently - # breaks the grouping contract. + # source → AR relation (consumed via find_each) + # initial_state → empty accumulator value + # accumulate(state, row) → mutates state in place; all grouping and domain + # math lives here, not in the base class. # # Subclasses may override (private): # finalize(state) → transforms finished state into the output hash @@ -60,7 +42,7 @@ def initialize(reportable) @reportable = reportable end - # Runs the reducer: stream → group → accumulate → finalize. + # Runs the reducer: stream → accumulate → finalize. # # Accepts an optional shared_state so that multiple reducers can write # into the same hash without a merge loop. When shared_state is provided, @@ -70,14 +52,14 @@ def initialize(reportable) # Benefits of this structure over writing report code directly: # 1. Memory safety — find_each streams in batches of 500 rather than # loading the entire relation into Ruby. Every report gets this for free. - # 2. New reports are just data — subclasses define source/state_key_for/accumulate/ - # finalize; the reducer wiring is not their concern. + # 2. New reports are just data — subclasses define source/initial_state/accumulate/finalize; + # the reducer wiring is not their concern. # 3. Single place for cross-cutting concerns — logging, timing, or error # handling can be added here once and applies to every report. def run(shared_state = nil) state = shared_state || initial_state source.find_each(batch_size: 500) do |row| - accumulate(state, state_key_for.call(row), row) + accumulate(state, row) end finalize(state) end @@ -85,10 +67,9 @@ def run(shared_state = nil) private def source = raise NotImplementedError, "#{self.class}#source" - def state_key_for = raise NotImplementedError, "#{self.class}#state_key_for" def initial_state = raise NotImplementedError, "#{self.class}#initial_state" - def accumulate(_state, _key, _row) + def accumulate(_state, _row) raise NotImplementedError, "#{self.class}#accumulate" end diff --git a/app/models/reports/review_report.rb b/app/models/reports/review_report.rb index 2720da4e6..6a4725b3c 100644 --- a/app/models/reports/review_report.rb +++ b/app/models/reports/review_report.rb @@ -65,11 +65,10 @@ def source ReviewResponseMap.for_assignment(@reportable.id).includes(reviewer: :user) end - def state_key_for = ->(response_map) { response_map.reviewer_id } - def initial_state = {} - def accumulate(state, reviewer_id, response_map) + def accumulate(state, response_map) + reviewer_id = response_map.reviewer_id return if state.key?(reviewer_id) reviewer = response_map.reviewer @@ -91,13 +90,12 @@ def accumulate(state, reviewer_id, response_map) class ScoresReducer < BaseReport include ReviewResponseShared - def state_key_for = ->(response) { response.response_map.reviewer_id } - def initial_state = {} - def accumulate(state, reviewer_id, response) + def accumulate(state, response) return if response.maximum_score.zero? + reviewer_id = response.response_map.reviewer_id reviewee_id = response.response_map.reviewee_id round = response.round || 1 score_pct = (response.calculate_total_score.to_f / response.maximum_score * 100).round(2) @@ -121,12 +119,10 @@ def source .includes(review_mappings: { responses: { scores: :item } }) end - def state_key_for = ->(team) { team.id } - def initial_state = {} - def accumulate(state, team_id, team) - state[team_id] = team.aggregate_review_grade + def accumulate(state, team) + state[team.id] = team.aggregate_review_grade end end end From 2ff9d8a2e1464a686c2ff6b510a5dd269fd73e8d Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Tue, 26 May 2026 15:50:45 -0400 Subject: [PATCH 25/41] Clean up ReviewReport: remove full_name, inline ScoresReducer source, fix comments - Drop full_name from shared state and ReviewersReducer; only name is needed - Remove ReviewResponseShared module; inline source directly in ScoresReducer - Remove stale eager-loading note from class-level comment (now in ScoresReducer) Co-Authored-By: Claude Sonnet 4.6 --- app/models/reports/review_report.rb | 48 ++++++++++++----------------- 1 file changed, 20 insertions(+), 28 deletions(-) diff --git a/app/models/reports/review_report.rb b/app/models/reports/review_report.rb index 6a4725b3c..17c31ee08 100644 --- a/app/models/reports/review_report.rb +++ b/app/models/reports/review_report.rb @@ -5,15 +5,14 @@ module Reports # # ReviewersReducer and ScoresReducer share a single state keyed by reviewer_id # so reviewer info and scores are co-located without a merge loop: - # { reviewer_id => { id:, user_id:, name:, full_name:, handle:, + # { reviewer_id => { id:, user_id:, name:, handle:, # scores: { reviewee_id => { round => score_pct } } } } # # AvgRangesReducer runs independently: # { team_id => avg_score } # # Each reducer streams its source via find_each — no full result set is ever - # materialised in Ruby at once. Scores and questionnaires are eagerly loaded - # to avoid N+1 inside calculate_total_score and maximum_score (ScorableHelper). + # materialised in Ruby at once. class ReviewReport def self.for_assignment(assignment) new(assignment) @@ -26,12 +25,11 @@ def initialize(reportable) def run shared_state = Hash.new do |state, reviewer_id| state[reviewer_id] = { - id: nil, - user_id: nil, - name: nil, - full_name: nil, - handle: nil, - scores: Hash.new { |by_reviewee, reviewee_id| by_reviewee[reviewee_id] = {} } + id: nil, + user_id: nil, + name: nil, + handle: nil, + scores: Hash.new { |by_reviewee, reviewee_id| by_reviewee[reviewee_id] = {} } } end @@ -39,22 +37,11 @@ def run ScoresReducer.new(@reportable).run(shared_state) { - reviewers: shared_state.values.sort_by { |r| r[:full_name].to_s.downcase }, + reviewers: shared_state.values.sort_by { |r| r[:name].to_s.downcase }, avg_and_ranges: AvgRangesReducer.new(@reportable).run } end - # Shared source for ScoresReducer. - # Streams submitted ReviewResponseMap responses with scores and questionnaires - # eagerly loaded to avoid N+1 inside calculate_total_score and maximum_score. - module ReviewResponseShared - def source - Response - .submitted_review_responses_for(@reportable.id) - .includes(:response_map, scores: { item: :questionnaire }) - end - end - # ----------------------------------------------------------------------- # Reducer 1 — reviewer info. # Streams ReviewResponseMap rows; writes reviewer details into shared state @@ -74,21 +61,26 @@ def accumulate(state, response_map) reviewer = response_map.reviewer return unless reviewer - state[reviewer_id][:id] = reviewer.id - state[reviewer_id][:user_id] = reviewer.user_id - state[reviewer_id][:name] = reviewer.user&.name - state[reviewer_id][:full_name] = reviewer.user&.full_name - state[reviewer_id][:handle] = reviewer.handle + state[reviewer_id][:id] = reviewer.id + state[reviewer_id][:user_id] = reviewer.user_id + state[reviewer_id][:name] = reviewer.user&.name + state[reviewer_id][:handle] = reviewer.handle end end # ----------------------------------------------------------------------- # Reducer 2 — per-reviewer × reviewee × round score percentages. - # Streams submitted Responses; writes score_pct into shared state under + # Streams submitted Responses with scores and questionnaires eagerly loaded + # to avoid N+1 inside calculate_total_score and maximum_score. + # Writes score_pct into shared state under # state[reviewer_id][:scores][reviewee_id][round]. # ----------------------------------------------------------------------- class ScoresReducer < BaseReport - include ReviewResponseShared + def source + Response + .submitted_review_responses_for(@reportable.id) + .includes(:response_map, scores: { item: :questionnaire }) + end def initial_state = {} From 41f39447a37a0df35230ca8e1af056e9b7818155 Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Thu, 28 May 2026 16:55:30 -0400 Subject: [PATCH 26/41] Tighten ReportsController: assignment-level auth and rename action to fetch_report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add before_action :set_assignment to load @assignment from params[:assignment_id] - Replace current_user_has_ta_privileges? with current_user_teaching_staff_of_assignment? so only instructors/TAs mapped to that specific assignment can view its reports - Reuse @assignment in fetch_report instead of re-fetching - Rename action/route from fetch_response_report to fetch_report — not all report types are response reports (e.g. basic, answer_tagging, bookmark_rating) Co-Authored-By: Claude Sonnet 4.6 --- app/controllers/reports_controller.rb | 39 ++++++++++++++++----------- config/routes.rb | 2 +- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index b4171d5eb..fa315b0fa 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -2,23 +2,25 @@ class ReportsController < ApplicationController REPORT_CLASSES = { - 'review_response_map' => Reports::ReviewReport, - 'feedback_response_map' => Reports::FeedbackReport, - 'teammate_review_response_map' => Reports::TeammateReviewReport, - 'bookmark_rating_response_map' => Reports::BookmarkRatingReport, - 'answer_tagging' => Reports::AnswerTaggingReport, - 'basic' => Reports::BasicReport + 'basic' => Reports::BasicReport, + 'review_response_map' => Reports::ReviewReport, + 'feedback_response_map' => Reports::FeedbackReport, + 'teammate_review_response_map' => Reports::TeammateReviewReport, + 'bookmark_rating_response_map' => Reports::BookmarkRatingReport, + 'answer_tagging' => Reports::AnswerTaggingReport }.freeze - # Only TAs, instructors, and admins may view reports. + before_action :set_assignment + before_action :authorize + + # Only teaching staff (instructor or TA) of the specific assignment may view reports. def action_allowed? - current_user_has_ta_privileges? + current_user_teaching_staff_of_assignment?(@assignment.id) end - # POST /reports/fetch_response_report + # POST /reports/fetch_report # Returns the requested report as JSON. - def fetch_response_report - assignment_id = params[:assignment_id] + def fetch_report type = params[:type] || 'basic' report_class = REPORT_CLASSES[type] @@ -28,12 +30,17 @@ def fetch_response_report }, status: :unprocessable_entity end - assignment = Assignment.find(assignment_id) - data = report_class.for_assignment(assignment).run - render json: { type: type, assignment_id: assignment_id.to_i }.merge(data) - rescue ActiveRecord::RecordNotFound - render json: { error: 'Assignment not found' }, status: :not_found + data = report_class.for_assignment(@assignment).run + render json: { type: type, assignment_id: @assignment.id }.merge(data) rescue StandardError => e render json: { error: e.message }, status: :internal_server_error end + + private + + def set_assignment + @assignment = Assignment.find(params[:assignment_id]) + rescue ActiveRecord::RecordNotFound + render json: { error: 'Assignment not found' }, status: :not_found + end end diff --git a/config/routes.rb b/config/routes.rb index a5c0e2b65..fa3f735aa 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -196,7 +196,7 @@ end resources :reports, only: [] do collection do - post :fetch_response_report + post :fetch_report end end From 8f2fa1832b8a1538ef8c9c77500a4474b62630db Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Thu, 28 May 2026 16:57:08 -0400 Subject: [PATCH 27/41] Fix wording: reduce-based -> reduction-based in BaseReport comment Co-Authored-By: Claude Sonnet 4.6 --- app/models/reports/base_report.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/reports/base_report.rb b/app/models/reports/base_report.rb index 8bad5a45f..dfe7276e3 100644 --- a/app/models/reports/base_report.rb +++ b/app/models/reports/base_report.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Reports - # Template for a streaming reduce-based report. + # Template for a streaming reduction-based report. # # Design rationale (addresses two anti-patterns from the naive approach): # From db61e268ab79e8679b491453f010d82833b3658d Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Thu, 28 May 2026 17:40:01 -0400 Subject: [PATCH 28/41] Improve comments in ReviewReport and BaseReport - Add explicit reducer descriptions to ReviewReport class comment - Add run method comment with full output shape - Fix British spellings: materialised -> materialized, normalises -> normalizes - Fix wording: reduce-based -> reduction-based in BaseReport Co-Authored-By: Claude Sonnet 4.6 --- app/models/reports/review_report.rb | 31 +++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/app/models/reports/review_report.rb b/app/models/reports/review_report.rb index 17c31ee08..466c3e3df 100644 --- a/app/models/reports/review_report.rb +++ b/app/models/reports/review_report.rb @@ -3,16 +3,25 @@ module Reports # Peer-review report composed of three streaming reducers. # - # ReviewersReducer and ScoresReducer share a single state keyed by reviewer_id - # so reviewer info and scores are co-located without a merge loop: - # { reviewer_id => { id:, user_id:, name:, handle:, - # scores: { reviewee_id => { round => score_pct } } } } + # Three reducers build the report: # - # AvgRangesReducer runs independently: - # { team_id => avg_score } + # ReviewersReducer — streams ReviewResponseMap rows to populate reviewer + # identity fields (id, user_id, name, handle) in shared state. + # + # ScoresReducer — streams submitted Responses to compute score percentages + # per reviewee per round, written into shared state under each reviewer. + # + # Both write into a shared state keyed by reviewer_id so reviewer info and + # scores are co-located without a merge loop: + # { reviewer_id => { id:, user_id:, name:, handle:, + # scores: { reviewee_id => { round => score_pct } } } } + # + # AvgRangesReducer — runs independently, streams AssignmentTeam rows to + # compute the average review score per team: + # { team_id => avg_score } # # Each reducer streams its source via find_each — no full result set is ever - # materialised in Ruby at once. + # materialized in Ruby at once. class ReviewReport def self.for_assignment(assignment) new(assignment) @@ -22,6 +31,12 @@ def initialize(reportable) @reportable = reportable end + # Runs all three reducers and returns the combined report: + # { + # reviewers: [{ id:, user_id:, name:, handle:, + # scores: { reviewee_id => { round => score_pct } } }, ...], + # avg_and_ranges: { team_id => avg_score } + # } def run shared_state = Hash.new do |state, reviewer_id| state[reviewer_id] = { @@ -101,7 +116,7 @@ def accumulate(state, response) # Streams AssignmentTeam rows with review_mappings, responses, and scores # eagerly loaded to avoid N+1. Delegates score computation to # aggregate_review_grade (via ReviewAggregator concern) which picks the - # latest submitted response per round per map and normalises the score. + # latest submitted response per round per map and normalizes the score. # Output: { team_id => avg_score } # ----------------------------------------------------------------------- class AvgRangesReducer < BaseReport From a3e205de581f8850640cfdb371986946ff108db1 Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Thu, 28 May 2026 18:02:56 -0400 Subject: [PATCH 29/41] Fix whitespace-only issues in routes.rb - Fix indentation of review_mappings block - Fix extra space on signed_up_teams member block - Remove trailing whitespace on get :view and put '/leave' lines - Remove trailing whitespace on collection do and end lines - Add missing newline at end of file Co-Authored-By: Claude Sonnet 4.6 --- config/routes.rb | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/config/routes.rb b/config/routes.rb index fa3f735aa..f8ee581b4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -77,23 +77,23 @@ end end - resources :review_mappings, only: [] do - collection do - post :assign_round_robin - post :assign_random - post :assign_from_csv - post :request_review_fewest - post :assign_calibration - post :assign_quiz - delete :delete_all_for_reviewer - end - - member do - patch :submit_review - patch :unsubmit_review - patch :grade_review - delete :delete_mapping - end + resources :review_mappings, only: [] do + collection do + post :assign_round_robin + post :assign_random + post :assign_from_csv + post :request_review_fewest + post :assign_calibration + post :assign_quiz + delete :delete_all_for_reviewer + end + + member do + patch :submit_review + patch :unsubmit_review + patch :grade_review + delete :delete_mapping + end end resources :signed_up_teams do @@ -101,13 +101,13 @@ post '/sign_up', to: 'signed_up_teams#sign_up' post '/sign_up_student', to: 'signed_up_teams#sign_up_student' end - member do + member do post :create_advertisement patch :update_advertisement delete :remove_advertisement end end - + resources :submitted_content do collection do get :download @@ -166,10 +166,10 @@ resources :student_teams, only: %i[create update] do collection do - get :view + get :view get :mentor get :remove_participant - put '/leave', to: 'student_teams#leave_team' + put '/leave', to: 'student_teams#leave_team' end end @@ -193,7 +193,7 @@ post :add_participant delete :delete_participants end - end + end resources :reports, only: [] do collection do post :fetch_report @@ -201,7 +201,7 @@ end resources :grades do - collection do + collection do get '/:assignment_id/view_all_scores', to: 'grades#view_all_scores' patch '/:participant_id/assign_grade', to: 'grades#assign_grade' get '/:participant_id/edit', to: 'grades#edit' @@ -225,4 +225,4 @@ resources :assignments do resources :duties, controller: 'assignments_duties', only: [:index, :create, :destroy] end -end \ No newline at end of file +end From 7c641e61f49fd3628e99f0b1ce6558b466014ebe Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Wed, 3 Jun 2026 18:45:24 -0400 Subject: [PATCH 30/41] Rename BaseReport to BaseReducer for semantic correctness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Reducer is-not-a Report — inner reducer classes inherit streaming scaffold behavior, not report behavior. Renaming makes the inheritance read correctly: ReviewersReducer < BaseReducer, etc. Co-Authored-By: Claude Sonnet 4.6 --- app/models/reports/{base_report.rb => base_reducer.rb} | 8 ++++---- app/models/reports/review_report.rb | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) rename app/models/reports/{base_report.rb => base_reducer.rb} (93%) diff --git a/app/models/reports/base_report.rb b/app/models/reports/base_reducer.rb similarity index 93% rename from app/models/reports/base_report.rb rename to app/models/reports/base_reducer.rb index dfe7276e3..ed9752d61 100644 --- a/app/models/reports/base_report.rb +++ b/app/models/reports/base_reducer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Reports - # Template for a streaming reduction-based report. + # Base class for streaming reducers. # # Design rationale (addresses two anti-patterns from the naive approach): # @@ -11,7 +11,7 @@ module Reports # find_each so memory usage scales with the number of *groups*, not rows. # # Anti-pattern 2 — "default metrics in base": encoding avg_score or any - # domain metric in the base class ties every report to one shape of math. + # domain metric in the base class ties every reducer to one shape of math. # This class contains *only* the reducer scaffold; each subclass owns its # accumulate/finalize logic entirely. # @@ -24,7 +24,7 @@ module Reports # Subclasses may override (private): # finalize(state) → transforms finished state into the output hash # (default: returns state unchanged) - class BaseReport + class BaseReducer # Factory method for assignment-scoped reports. def self.for_assignment(assignment) new(assignment) @@ -49,7 +49,7 @@ def initialize(reportable) # initial_state is ignored — the coordinator owns state initialization. # finalize is always called, even when shared_state is provided. # - # Benefits of this structure over writing report code directly: + # Benefits of this structure over writing reducer code directly: # 1. Memory safety — find_each streams in batches of 500 rather than # loading the entire relation into Ruby. Every report gets this for free. # 2. New reports are just data — subclasses define source/initial_state/accumulate/finalize; diff --git a/app/models/reports/review_report.rb b/app/models/reports/review_report.rb index 466c3e3df..df84625c5 100644 --- a/app/models/reports/review_report.rb +++ b/app/models/reports/review_report.rb @@ -62,7 +62,7 @@ def run # Streams ReviewResponseMap rows; writes reviewer details into shared state # on first occurrence per reviewer. # ----------------------------------------------------------------------- - class ReviewersReducer < BaseReport + class ReviewersReducer < BaseReducer def source ReviewResponseMap.for_assignment(@reportable.id).includes(reviewer: :user) end @@ -90,7 +90,7 @@ def accumulate(state, response_map) # Writes score_pct into shared state under # state[reviewer_id][:scores][reviewee_id][round]. # ----------------------------------------------------------------------- - class ScoresReducer < BaseReport + class ScoresReducer < BaseReducer def source Response .submitted_review_responses_for(@reportable.id) @@ -119,7 +119,7 @@ def accumulate(state, response) # latest submitted response per round per map and normalizes the score. # Output: { team_id => avg_score } # ----------------------------------------------------------------------- - class AvgRangesReducer < BaseReport + class AvgRangesReducer < BaseReducer def source AssignmentTeam .where(parent_id: @reportable.id) From 33cf6c132db655c9f218c221a382452fd9ec02f9 Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Wed, 3 Jun 2026 18:46:44 -0400 Subject: [PATCH 31/41] =?UTF-8?q?Add=20BasicReport=20=E2=80=94=20minimal?= =?UTF-8?q?=20assignment=20metadata=20fallback=20report?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- app/models/reports/basic_report.rb | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 app/models/reports/basic_report.rb diff --git a/app/models/reports/basic_report.rb b/app/models/reports/basic_report.rb new file mode 100644 index 000000000..d6e2847cb --- /dev/null +++ b/app/models/reports/basic_report.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Reports + # Basic report: minimal reportable metadata. + # Used as a fallback when no specific report type is requested. + # No streaming needed — all data comes from the already-loaded reportable. + class BasicReport + # Factory method for assignment-scoped reports. + def self.for_assignment(assignment) + new(assignment) + end + + # Factory method for course-scoped reports. + def self.for_course(course) + new(course) + end + + # @param reportable [Assignment, Course] the object the report is scoped to. + def initialize(reportable) + @reportable = reportable + end + + def run + { + reportable: { + id: @reportable.id, + name: @reportable.name, + num_review_rounds: @reportable.num_review_rounds, + varying_rubrics_by_round: @reportable.varying_rubrics_by_round? + } + } + end + end +end From b53c92a35c338debce4c3dd5610ab8e031089b3a Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Thu, 11 Jun 2026 18:57:57 -0400 Subject: [PATCH 32/41] Simplify ReviewReport: replace ReviewersReducer with AR query + as_json - Replace ReviewersReducer + shared state with a single AR query on ReviewResponseMap serialized via as_json(MAP_JSON_OPTIONS) - MAP_JSON_OPTIONS whitelist keeps output clean (no raw FKs, timestamps, STI type) - ScoresReducer now runs standalone with its own nested Hash.new initial_state - AvgRangesReducer unchanged - Output shape: { reviews: [...], scores: {...}, avg_and_ranges: {...} } Co-Authored-By: Claude Sonnet 4.6 --- app/models/reports/review_report.rb | 132 ++++++++++++++-------------- 1 file changed, 64 insertions(+), 68 deletions(-) diff --git a/app/models/reports/review_report.rb b/app/models/reports/review_report.rb index df84625c5..fe71cc1ef 100644 --- a/app/models/reports/review_report.rb +++ b/app/models/reports/review_report.rb @@ -1,28 +1,61 @@ # frozen_string_literal: true module Reports - # Peer-review report composed of three streaming reducers. + # Peer-review report: returns raw review rows (reviewer, reviewee, responses, + # scores) plus computed score percentages per reviewer and per-team averages. # - # Three reducers build the report: + # Three data sources: # - # ReviewersReducer — streams ReviewResponseMap rows to populate reviewer - # identity fields (id, user_id, name, handle) in shared state. + # Review rows — a single AR query on ReviewResponseMap with all associations + # eagerly loaded, serialized via as_json. Each row contains reviewer, + # reviewee, and full response/score content. # # ScoresReducer — streams submitted Responses to compute score percentages - # per reviewee per round, written into shared state under each reviewer. + # per reviewer × reviewee × round. + # Output: { reviewer_id => { reviewee_id => { round => score_pct } } } # - # Both write into a shared state keyed by reviewer_id so reviewer info and - # scores are co-located without a merge loop: - # { reviewer_id => { id:, user_id:, name:, handle:, - # scores: { reviewee_id => { round => score_pct } } } } + # AvgRangesReducer — streams AssignmentTeam rows to compute the average + # review score per team. + # Output: { team_id => avg_score } # - # AvgRangesReducer — runs independently, streams AssignmentTeam rows to - # compute the average review score per team: - # { team_id => avg_score } - # - # Each reducer streams its source via find_each — no full result set is ever - # materialized in Ruby at once. + # Output: + # { + # reviews: [ { id:, + # reviewer: { id:, user: { id:, name: } }, + # reviewee: { id:, user: { id:, name: } }, + # responses: [ { id:, round:, is_submitted:, additional_comment:, + # scores: [ { id:, answer:, comments:, + # item: { id:, txt:, weight: } } ] } ] + # }, ... ], + # scores: { reviewer_id => { reviewee_id => { round => score_pct } } }, + # avg_and_ranges: { team_id => avg_score } + # } class ReviewReport + # Whitelist for as_json — includes only fields the frontend needs, + # excluding internal columns (raw FKs, timestamps, STI type, etc.). + MAP_JSON_OPTIONS = { + only: [:id], + include: { + reviewer: { + only: [:id], + include: { user: { only: %i[id name] } } + }, + reviewee: { + only: [:id], + include: { user: { only: %i[id name] } } + }, + responses: { + only: %i[id round additional_comment is_submitted], + include: { + scores: { + only: %i[id answer comments], + include: { item: { only: %i[id txt weight] } } + } + } + } + } + }.freeze + def self.for_assignment(assignment) new(assignment) end @@ -31,64 +64,23 @@ def initialize(reportable) @reportable = reportable end - # Runs all three reducers and returns the combined report: - # { - # reviewers: [{ id:, user_id:, name:, handle:, - # scores: { reviewee_id => { round => score_pct } } }, ...], - # avg_and_ranges: { team_id => avg_score } - # } def run - shared_state = Hash.new do |state, reviewer_id| - state[reviewer_id] = { - id: nil, - user_id: nil, - name: nil, - handle: nil, - scores: Hash.new { |by_reviewee, reviewee_id| by_reviewee[reviewee_id] = {} } - } - end - - ReviewersReducer.new(@reportable).run(shared_state) - ScoresReducer.new(@reportable).run(shared_state) + maps = ReviewResponseMap + .for_assignment(@reportable.id) + .includes(reviewer: :user, reviewee: :user, responses: { scores: :item }) { - reviewers: shared_state.values.sort_by { |r| r[:name].to_s.downcase }, + reviews: maps.as_json(MAP_JSON_OPTIONS), + scores: ScoresReducer.new(@reportable).run, avg_and_ranges: AvgRangesReducer.new(@reportable).run } end # ----------------------------------------------------------------------- - # Reducer 1 — reviewer info. - # Streams ReviewResponseMap rows; writes reviewer details into shared state - # on first occurrence per reviewer. - # ----------------------------------------------------------------------- - class ReviewersReducer < BaseReducer - def source - ReviewResponseMap.for_assignment(@reportable.id).includes(reviewer: :user) - end - - def initial_state = {} - - def accumulate(state, response_map) - reviewer_id = response_map.reviewer_id - return if state.key?(reviewer_id) - - reviewer = response_map.reviewer - return unless reviewer - - state[reviewer_id][:id] = reviewer.id - state[reviewer_id][:user_id] = reviewer.user_id - state[reviewer_id][:name] = reviewer.user&.name - state[reviewer_id][:handle] = reviewer.handle - end - end - - # ----------------------------------------------------------------------- - # Reducer 2 — per-reviewer × reviewee × round score percentages. + # Reducer 1 — per-reviewer × reviewee × round score percentages. # Streams submitted Responses with scores and questionnaires eagerly loaded # to avoid N+1 inside calculate_total_score and maximum_score. - # Writes score_pct into shared state under - # state[reviewer_id][:scores][reviewee_id][round]. + # Output: { reviewer_id => { reviewee_id => { round => score_pct } } } # ----------------------------------------------------------------------- class ScoresReducer < BaseReducer def source @@ -97,7 +89,11 @@ def source .includes(:response_map, scores: { item: :questionnaire }) end - def initial_state = {} + def initial_state + Hash.new do |state, reviewer_id| + state[reviewer_id] = Hash.new { |by_reviewee, reviewee_id| by_reviewee[reviewee_id] = {} } + end + end def accumulate(state, response) return if response.maximum_score.zero? @@ -107,16 +103,16 @@ def accumulate(state, response) round = response.round || 1 score_pct = (response.calculate_total_score.to_f / response.maximum_score * 100).round(2) - state[reviewer_id][:scores][reviewee_id][round] = score_pct + state[reviewer_id][reviewee_id][round] = score_pct end end # ----------------------------------------------------------------------- - # Reducer 3 — per-team average review score. + # Reducer 2 — per-team average review score. # Streams AssignmentTeam rows with review_mappings, responses, and scores # eagerly loaded to avoid N+1. Delegates score computation to - # aggregate_review_grade (via ReviewAggregator concern) which picks the - # latest submitted response per round per map and normalizes the score. + # aggregate_review_grade which picks the latest submitted response per + # round per map and normalizes the score. # Output: { team_id => avg_score } # ----------------------------------------------------------------------- class AvgRangesReducer < BaseReducer From cde422b4df1a351a31f226b7834c2fc3beab9e4d Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Mon, 15 Jun 2026 18:48:07 -0400 Subject: [PATCH 33/41] Fix ReviewReport scores, remove Reports namespace, add review seed data - Fix ScoresReducer to use aggregate_questionnaire_score instead of calculate_total_score (which always returned 0 due to Item#scorable? returning false for non-STI base class instances) - Remove module Reports namespace from all report files and update controller REPORT_CLASSES references accordingly - Add seed data for review report: questionnaire, items, AssignmentQuestionnaire (rounds 1 & 2), ReviewResponseMaps, Responses, and Answers using find_or_create_by for idempotency - Remove unimplemented report types from REPORT_CLASSES to prevent NameError on controller load Co-Authored-By: Claude Sonnet 4.6 --- app/controllers/reports_controller.rb | 8 +- app/models/reports/base_reducer.rb | 132 +++++++++++++------------- app/models/reports/basic_report.rb | 26 +++-- app/models/reports/review_report.rb | 70 +++++++------- db/schema.rb | 44 ++++++++- db/seeds.rb | 95 ++++++++++++++++++ 6 files changed, 250 insertions(+), 125 deletions(-) diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index fa315b0fa..a6d5ab146 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -2,12 +2,8 @@ class ReportsController < ApplicationController REPORT_CLASSES = { - 'basic' => Reports::BasicReport, - 'review_response_map' => Reports::ReviewReport, - 'feedback_response_map' => Reports::FeedbackReport, - 'teammate_review_response_map' => Reports::TeammateReviewReport, - 'bookmark_rating_response_map' => Reports::BookmarkRatingReport, - 'answer_tagging' => Reports::AnswerTaggingReport + 'basic' => BasicReport, + 'review_response_map' => ReviewReport }.freeze before_action :set_assignment diff --git a/app/models/reports/base_reducer.rb b/app/models/reports/base_reducer.rb index ed9752d61..f7b4ec921 100644 --- a/app/models/reports/base_reducer.rb +++ b/app/models/reports/base_reducer.rb @@ -1,78 +1,76 @@ # frozen_string_literal: true -module Reports - # Base class for streaming reducers. - # - # Design rationale (addresses two anti-patterns from the naive approach): - # - # Anti-pattern 1 — "fetch_responses": loading all records into an unnamed - # ad-hoc array before processing wastes memory and forces the entire result - # set into Ruby-land. Instead, #run streams the source relation via - # find_each so memory usage scales with the number of *groups*, not rows. - # - # Anti-pattern 2 — "default metrics in base": encoding avg_score or any - # domain metric in the base class ties every reducer to one shape of math. - # This class contains *only* the reducer scaffold; each subclass owns its - # accumulate/finalize logic entirely. - # - # Subclasses must implement (private): - # source → AR relation (consumed via find_each) - # initial_state → empty accumulator value - # accumulate(state, row) → mutates state in place; all grouping and domain - # math lives here, not in the base class. - # - # Subclasses may override (private): - # finalize(state) → transforms finished state into the output hash - # (default: returns state unchanged) - class BaseReducer - # Factory method for assignment-scoped reports. - def self.for_assignment(assignment) - new(assignment) - end +# Base class for streaming reducers. +# +# Design rationale (addresses two anti-patterns from the naive approach): +# +# Anti-pattern 1 — "fetch_responses": loading all records into an unnamed +# ad-hoc array before processing wastes memory and forces the entire result +# set into Ruby-land. Instead, #run streams the source relation via +# find_each so memory usage scales with the number of *groups*, not rows. +# +# Anti-pattern 2 — "default metrics in base": encoding avg_score or any +# domain metric in the base class ties every reducer to one shape of math. +# This class contains *only* the reducer scaffold; each subclass owns its +# accumulate/finalize logic entirely. +# +# Subclasses must implement (private): +# source → AR relation (consumed via find_each) +# initial_state → empty accumulator value +# accumulate(state, row) → mutates state in place; all grouping and domain +# math lives here, not in the base class. +# +# Subclasses may override (private): +# finalize(state) → transforms finished state into the output hash +# (default: returns state unchanged) +class BaseReducer + # Factory method for assignment-scoped reports. + def self.for_assignment(assignment) + new(assignment) + end - # Factory method for course-scoped reports. - def self.for_course(course) - new(course) - end + # Factory method for course-scoped reports. + def self.for_course(course) + new(course) + end - # @param reportable [Assignment, Course] the object the report is scoped to. - # Subclasses reference @reportable instead of a type-specific variable so - # the same reducer works for any reportable entity. - def initialize(reportable) - @reportable = reportable - end + # @param reportable [Assignment, Course] the object the report is scoped to. + # Subclasses reference @reportable instead of a type-specific variable so + # the same reducer works for any reportable entity. + def initialize(reportable) + @reportable = reportable + end - # Runs the reducer: stream → accumulate → finalize. - # - # Accepts an optional shared_state so that multiple reducers can write - # into the same hash without a merge loop. When shared_state is provided, - # initial_state is ignored — the coordinator owns state initialization. - # finalize is always called, even when shared_state is provided. - # - # Benefits of this structure over writing reducer code directly: - # 1. Memory safety — find_each streams in batches of 500 rather than - # loading the entire relation into Ruby. Every report gets this for free. - # 2. New reports are just data — subclasses define source/initial_state/accumulate/finalize; - # the reducer wiring is not their concern. - # 3. Single place for cross-cutting concerns — logging, timing, or error - # handling can be added here once and applies to every report. - def run(shared_state = nil) - state = shared_state || initial_state - source.find_each(batch_size: 500) do |row| - accumulate(state, row) - end - finalize(state) + # Runs the reducer: stream → accumulate → finalize. + # + # Accepts an optional shared_state so that multiple reducers can write + # into the same hash without a merge loop. When shared_state is provided, + # initial_state is ignored — the coordinator owns state initialization. + # finalize is always called, even when shared_state is provided. + # + # Benefits of this structure over writing reducer code directly: + # 1. Memory safety — find_each streams in batches of 500 rather than + # loading the entire relation into Ruby. Every report gets this for free. + # 2. New reports are just data — subclasses define source/initial_state/accumulate/finalize; + # the reducer wiring is not their concern. + # 3. Single place for cross-cutting concerns — logging, timing, or error + # handling can be added here once and applies to every report. + def run(shared_state = nil) + state = shared_state || initial_state + source.find_each(batch_size: 500) do |row| + accumulate(state, row) end + finalize(state) + end - private - - def source = raise NotImplementedError, "#{self.class}#source" - def initial_state = raise NotImplementedError, "#{self.class}#initial_state" + private - def accumulate(_state, _row) - raise NotImplementedError, "#{self.class}#accumulate" - end + def source = raise NotImplementedError, "#{self.class}#source" + def initial_state = raise NotImplementedError, "#{self.class}#initial_state" - def finalize(state) = state + def accumulate(_state, _row) + raise NotImplementedError, "#{self.class}#accumulate" end + + def finalize(state) = state end diff --git a/app/models/reports/basic_report.rb b/app/models/reports/basic_report.rb index d6e2847cb..1f99100e9 100644 --- a/app/models/reports/basic_report.rb +++ b/app/models/reports/basic_report.rb @@ -1,10 +1,9 @@ # frozen_string_literal: true -module Reports - # Basic report: minimal reportable metadata. - # Used as a fallback when no specific report type is requested. - # No streaming needed — all data comes from the already-loaded reportable. - class BasicReport +# Basic report: minimal reportable metadata. +# Used as a fallback when no specific report type is requested. +# No streaming needed — all data comes from the already-loaded reportable. +class BasicReport # Factory method for assignment-scoped reports. def self.for_assignment(assignment) new(assignment) @@ -20,15 +19,14 @@ def initialize(reportable) @reportable = reportable end - def run - { - reportable: { - id: @reportable.id, - name: @reportable.name, - num_review_rounds: @reportable.num_review_rounds, - varying_rubrics_by_round: @reportable.varying_rubrics_by_round? - } + def run + { + reportable: { + id: @reportable.id, + name: @reportable.name, + num_review_rounds: @reportable.num_review_rounds, + varying_rubrics_by_round: @reportable.varying_rubrics_by_round? } - end + } end end diff --git a/app/models/reports/review_report.rb b/app/models/reports/review_report.rb index fe71cc1ef..0839536c6 100644 --- a/app/models/reports/review_report.rb +++ b/app/models/reports/review_report.rb @@ -1,36 +1,35 @@ # frozen_string_literal: true -module Reports - # Peer-review report: returns raw review rows (reviewer, reviewee, responses, - # scores) plus computed score percentages per reviewer and per-team averages. - # - # Three data sources: - # - # Review rows — a single AR query on ReviewResponseMap with all associations - # eagerly loaded, serialized via as_json. Each row contains reviewer, - # reviewee, and full response/score content. - # - # ScoresReducer — streams submitted Responses to compute score percentages - # per reviewer × reviewee × round. - # Output: { reviewer_id => { reviewee_id => { round => score_pct } } } - # - # AvgRangesReducer — streams AssignmentTeam rows to compute the average - # review score per team. - # Output: { team_id => avg_score } - # - # Output: - # { - # reviews: [ { id:, - # reviewer: { id:, user: { id:, name: } }, - # reviewee: { id:, user: { id:, name: } }, - # responses: [ { id:, round:, is_submitted:, additional_comment:, - # scores: [ { id:, answer:, comments:, - # item: { id:, txt:, weight: } } ] } ] - # }, ... ], - # scores: { reviewer_id => { reviewee_id => { round => score_pct } } }, - # avg_and_ranges: { team_id => avg_score } - # } - class ReviewReport +# Peer-review report: returns raw review rows (reviewer, reviewee, responses, +# scores) plus computed score percentages per reviewer and per-team averages. +# +# Three data sources: +# +# Review rows — a single AR query on ReviewResponseMap with all associations +# eagerly loaded, serialized via as_json. Each row contains reviewer, +# reviewee, and full response/score content. +# +# ScoresReducer — streams submitted Responses to compute score percentages +# per reviewer × reviewee × round. +# Output: { reviewer_id => { reviewee_id => { round => score_pct } } } +# +# AvgRangesReducer — streams AssignmentTeam rows to compute the average +# review score per team. +# Output: { team_id => avg_score } +# +# Output: +# { +# reviews: [ { id:, +# reviewer: { id:, user: { id:, name: } }, +# reviewee: { id:, user: { id:, name: } }, +# responses: [ { id:, round:, is_submitted:, additional_comment:, +# scores: [ { id:, answer:, comments:, +# item: { id:, txt:, weight: } } ] } ] +# }, ... ], +# scores: { reviewer_id => { reviewee_id => { round => score_pct } } }, +# avg_and_ranges: { team_id => avg_score } +# } +class ReviewReport # Whitelist for as_json — includes only fields the frontend needs, # excluding internal columns (raw FKs, timestamps, STI type, etc.). MAP_JSON_OPTIONS = { @@ -82,7 +81,7 @@ def run # to avoid N+1 inside calculate_total_score and maximum_score. # Output: { reviewer_id => { reviewee_id => { round => score_pct } } } # ----------------------------------------------------------------------- - class ScoresReducer < BaseReducer + class ScoresReducer < BaseReducer def source Response .submitted_review_responses_for(@reportable.id) @@ -101,11 +100,11 @@ def accumulate(state, response) reviewer_id = response.response_map.reviewer_id reviewee_id = response.response_map.reviewee_id round = response.round || 1 - score_pct = (response.calculate_total_score.to_f / response.maximum_score * 100).round(2) + score_pct = (response.aggregate_questionnaire_score.to_f / response.maximum_score * 100).round(2) state[reviewer_id][reviewee_id][round] = score_pct end - end + end # ----------------------------------------------------------------------- # Reducer 2 — per-team average review score. @@ -115,7 +114,7 @@ def accumulate(state, response) # round per map and normalizes the score. # Output: { team_id => avg_score } # ----------------------------------------------------------------------- - class AvgRangesReducer < BaseReducer + class AvgRangesReducer < BaseReducer def source AssignmentTeam .where(parent_id: @reportable.id) @@ -127,6 +126,5 @@ def initial_state = {} def accumulate(state, team) state[team.id] = team.aggregate_review_grade end - end end end diff --git a/db/schema.rb b/db/schema.rb index cddbe12c6..21016ea67 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_03_13_064334) do +ActiveRecord::Schema[8.0].define(version: 2026_05_18_000003) do create_table "account_requests", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "username" t.string "full_name" @@ -25,6 +25,19 @@ t.index ["role_id"], name: "index_account_requests_on_role_id" end + create_table "answer_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "answer_id", null: false + t.bigint "tag_prompt_deployment_id", null: false + t.bigint "user_id", null: false + t.string "value", null: false + t.decimal "confidence_level", precision: 10, scale: 5 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["answer_id"], name: "index_answer_tags_on_answer_id" + t.index ["tag_prompt_deployment_id"], name: "index_answer_tags_on_tag_prompt_deployment_id" + t.index ["user_id"], name: "index_answer_tags_on_user_id" + end + create_table "answers", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.integer "item_id", default: 0, null: false t.integer "response_id" @@ -388,6 +401,27 @@ t.index ["user_id"], name: "index_ta_mappings_on_user_id" end + create_table "tag_prompt_deployments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "tag_prompt_id", null: false + t.bigint "assignment_id", null: false + t.bigint "questionnaire_id", null: false + t.string "question_type" + t.integer "answer_length_threshold" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["assignment_id"], name: "index_tag_prompt_deployments_on_assignment_id" + t.index ["questionnaire_id"], name: "index_tag_prompt_deployments_on_questionnaire_id" + t.index ["tag_prompt_id"], name: "index_tag_prompt_deployments_on_tag_prompt_id" + end + + create_table "tag_prompts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "prompt", null: false + t.string "desc", null: false + t.string "control_type", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "teams", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -450,15 +484,18 @@ add_foreign_key "account_requests", "institutions" add_foreign_key "account_requests", "roles" + add_foreign_key "answer_tags", "answers" + add_foreign_key "answer_tags", "tag_prompt_deployments" + add_foreign_key "answer_tags", "users" add_foreign_key "assignments", "courses" add_foreign_key "assignments", "users", column: "instructor_id" add_foreign_key "assignments_duties", "assignments" add_foreign_key "assignments_duties", "duties" add_foreign_key "courses", "institutions" add_foreign_key "courses", "users", column: "instructor_id" + add_foreign_key "duties", "users", column: "instructor_id" add_foreign_key "invitations", "participants", column: "from_id" add_foreign_key "invitations", "participants", column: "to_id" - add_foreign_key "duties", "users", column: "instructor_id" add_foreign_key "items", "questionnaires" add_foreign_key "participants", "duties" add_foreign_key "participants", "join_team_requests" @@ -471,6 +508,9 @@ add_foreign_key "signed_up_teams", "teams" add_foreign_key "ta_mappings", "courses" add_foreign_key "ta_mappings", "users" + add_foreign_key "tag_prompt_deployments", "assignments" + add_foreign_key "tag_prompt_deployments", "questionnaires" + add_foreign_key "tag_prompt_deployments", "tag_prompts" add_foreign_key "teams_participants", "participants" add_foreign_key "teams_participants", "teams" add_foreign_key "teams_users", "teams" diff --git a/db/seeds.rb b/db/seeds.rb index d49c80f33..e5a34eebb 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -138,3 +138,98 @@ rescue ActiveRecord::RecordInvalid => e puts e, 'The db has already been seeded' end + +# --------------------------------------------------------------------------- +# Review report seed data +# Creates the questionnaire, questions, response maps, responses, and answers +# needed to exercise the ReviewReport for the first assignment. +# --------------------------------------------------------------------------- +begin + assignment = Assignment.first + instructor = User.find_by(role_id: 3) + + puts "creating review questionnaire" + questionnaire = Questionnaire.create!( + name: 'Peer Review Rubric', + instructor_id: instructor.id, + private: false, + min_question_score: 0, + max_question_score: 5, + questionnaire_type: 'ReviewQuestionnaire' + ) + + puts "creating questionnaire items" + items = [ + 'How well did the team communicate?', + 'How thoroughly were the deliverables completed?', + 'Rate the overall quality of the work.' + ].each_with_index.map do |txt, i| + Item.create!( + questionnaire_id: questionnaire.id, + txt: txt, + weight: 1, + seq: i + 1, + question_type: 'Criterion', + break_before: true + ) + end + + puts "linking questionnaire to assignment" + AssignmentQuestionnaire.find_or_create_by!( + assignment_id: assignment.id, + questionnaire_id: questionnaire.id, + used_in_round: 1 + ) { |aq| aq.questionnaire_weight = 100; aq.notification_limit = 15 } + + AssignmentQuestionnaire.find_or_create_by!( + assignment_id: assignment.id, + questionnaire_id: questionnaire.id, + used_in_round: 2 + ) { |aq| aq.questionnaire_weight = 100; aq.notification_limit = 15 } + + teams = AssignmentTeam.where(parent_id: assignment.id).to_a + participants = AssignmentParticipant.where(parent_id: assignment.id).to_a + + puts "creating review response maps, responses, and answers" + participants.each_with_index do |reviewer, idx| + reviewee_team = teams.reject { |t| t.id == reviewer.team_id }.first + next if reviewee_team.nil? + + # One map per reviewer-reviewee pair — idempotent + map = ReviewResponseMap.find_or_create_by!( + reviewed_object_id: assignment.id, + reviewer_id: reviewer.id, + reviewee_id: reviewee_team.id + ) + + # Round 1 — all reviewers + r1 = Response.find_or_create_by!(map_id: map.id, round: 1) do |r| + r.is_submitted = true + r.additional_comment = Faker::Lorem.sentence + end + items.each do |item| + Answer.find_or_create_by!(response_id: r1.id, item_id: item.id) do |a| + a.answer = rand(3..5) + a.comments = Faker::Lorem.sentence + end + end + + # Round 2 — first reviewer only, so scores vs avg_and_ranges differ visibly + next unless idx.zero? + + r2 = Response.find_or_create_by!(map_id: map.id, round: 2) do |r| + r.is_submitted = true + r.additional_comment = Faker::Lorem.sentence + end + items.each do |item| + Answer.find_or_create_by!(response_id: r2.id, item_id: item.id) do |a| + a.answer = rand(1..3) + a.comments = Faker::Lorem.sentence + end + end + end + + puts "Review report seed data created successfully." +rescue ActiveRecord::RecordInvalid => e + puts e, 'Review report seed data may already exist.' +end From e8b6bc3cec65f0a10ac8f52b234ac881f7e8dd2f Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Mon, 15 Jun 2026 18:59:30 -0400 Subject: [PATCH 34/41] Restore module Reports namespace for Zeitwerk autoloading compatibility Zeitwerk maps app/models/reports/*.rb to Reports::* constants, so the module wrapper is required. Reverts the namespace removal and restores Reports::BasicReport and Reports::ReviewReport references in the controller. Co-Authored-By: Claude Sonnet 4.6 --- app/controllers/reports_controller.rb | 4 +- app/models/reports/base_reducer.rb | 4 +- app/models/reports/basic_report.rb | 10 ++-- app/models/reports/review_report.rb | 70 ++++++++++++++------------- 4 files changed, 47 insertions(+), 41 deletions(-) diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index a6d5ab146..9f94af676 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -2,8 +2,8 @@ class ReportsController < ApplicationController REPORT_CLASSES = { - 'basic' => BasicReport, - 'review_response_map' => ReviewReport + 'basic' => Reports::BasicReport, + 'review_response_map' => Reports::ReviewReport }.freeze before_action :set_assignment diff --git a/app/models/reports/base_reducer.rb b/app/models/reports/base_reducer.rb index f7b4ec921..4f3f7a96d 100644 --- a/app/models/reports/base_reducer.rb +++ b/app/models/reports/base_reducer.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true -# Base class for streaming reducers. +module Reports + # Base class for streaming reducers. # # Design rationale (addresses two anti-patterns from the naive approach): # @@ -73,4 +74,5 @@ def accumulate(_state, _row) end def finalize(state) = state + end end diff --git a/app/models/reports/basic_report.rb b/app/models/reports/basic_report.rb index 1f99100e9..a1b5ac614 100644 --- a/app/models/reports/basic_report.rb +++ b/app/models/reports/basic_report.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true -# Basic report: minimal reportable metadata. -# Used as a fallback when no specific report type is requested. -# No streaming needed — all data comes from the already-loaded reportable. -class BasicReport +module Reports + # Basic report: minimal reportable metadata. + # Used as a fallback when no specific report type is requested. + # No streaming needed — all data comes from the already-loaded reportable. + class BasicReport # Factory method for assignment-scoped reports. def self.for_assignment(assignment) new(assignment) @@ -30,3 +31,4 @@ def run } end end +end diff --git a/app/models/reports/review_report.rb b/app/models/reports/review_report.rb index 0839536c6..aa8d5add1 100644 --- a/app/models/reports/review_report.rb +++ b/app/models/reports/review_report.rb @@ -1,35 +1,36 @@ # frozen_string_literal: true -# Peer-review report: returns raw review rows (reviewer, reviewee, responses, -# scores) plus computed score percentages per reviewer and per-team averages. -# -# Three data sources: -# -# Review rows — a single AR query on ReviewResponseMap with all associations -# eagerly loaded, serialized via as_json. Each row contains reviewer, -# reviewee, and full response/score content. -# -# ScoresReducer — streams submitted Responses to compute score percentages -# per reviewer × reviewee × round. -# Output: { reviewer_id => { reviewee_id => { round => score_pct } } } -# -# AvgRangesReducer — streams AssignmentTeam rows to compute the average -# review score per team. -# Output: { team_id => avg_score } -# -# Output: -# { -# reviews: [ { id:, -# reviewer: { id:, user: { id:, name: } }, -# reviewee: { id:, user: { id:, name: } }, -# responses: [ { id:, round:, is_submitted:, additional_comment:, -# scores: [ { id:, answer:, comments:, -# item: { id:, txt:, weight: } } ] } ] -# }, ... ], -# scores: { reviewer_id => { reviewee_id => { round => score_pct } } }, -# avg_and_ranges: { team_id => avg_score } -# } -class ReviewReport +module Reports + # Peer-review report: returns raw review rows (reviewer, reviewee, responses, + # scores) plus computed score percentages per reviewer and per-team averages. + # + # Three data sources: + # + # Review rows — a single AR query on ReviewResponseMap with all associations + # eagerly loaded, serialized via as_json. Each row contains reviewer, + # reviewee, and full response/score content. + # + # ScoresReducer — streams submitted Responses to compute score percentages + # per reviewer × reviewee × round. + # Output: { reviewer_id => { reviewee_id => { round => score_pct } } } + # + # AvgRangesReducer — streams AssignmentTeam rows to compute the average + # review score per team. + # Output: { team_id => avg_score } + # + # Output: + # { + # reviews: [ { id:, + # reviewer: { id:, user: { id:, name: } }, + # reviewee: { id:, user: { id:, name: } }, + # responses: [ { id:, round:, is_submitted:, additional_comment:, + # scores: [ { id:, answer:, comments:, + # item: { id:, txt:, weight: } } ] } ] + # }, ... ], + # scores: { reviewer_id => { reviewee_id => { round => score_pct } } }, + # avg_and_ranges: { team_id => avg_score } + # } + class ReviewReport # Whitelist for as_json — includes only fields the frontend needs, # excluding internal columns (raw FKs, timestamps, STI type, etc.). MAP_JSON_OPTIONS = { @@ -81,7 +82,7 @@ def run # to avoid N+1 inside calculate_total_score and maximum_score. # Output: { reviewer_id => { reviewee_id => { round => score_pct } } } # ----------------------------------------------------------------------- - class ScoresReducer < BaseReducer + class ScoresReducer < BaseReducer def source Response .submitted_review_responses_for(@reportable.id) @@ -104,7 +105,7 @@ def accumulate(state, response) state[reviewer_id][reviewee_id][round] = score_pct end - end + end # ----------------------------------------------------------------------- # Reducer 2 — per-team average review score. @@ -114,7 +115,7 @@ def accumulate(state, response) # round per map and normalizes the score. # Output: { team_id => avg_score } # ----------------------------------------------------------------------- - class AvgRangesReducer < BaseReducer + class AvgRangesReducer < BaseReducer def source AssignmentTeam .where(parent_id: @reportable.id) @@ -126,5 +127,6 @@ def initial_state = {} def accumulate(state, team) state[team.id] = team.aggregate_review_grade end + end end -end +end \ No newline at end of file From 0a5e6e1062e0f4560eec9d74f94482d0bd80aab4 Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Mon, 15 Jun 2026 19:03:26 -0400 Subject: [PATCH 35/41] Improve ReviewReport comments to accurately describe each data source Co-Authored-By: Claude Sonnet 4.6 --- app/models/reports/review_report.rb | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/app/models/reports/review_report.rb b/app/models/reports/review_report.rb index aa8d5add1..b2229fdee 100644 --- a/app/models/reports/review_report.rb +++ b/app/models/reports/review_report.rb @@ -6,16 +6,17 @@ module Reports # # Three data sources: # - # Review rows — a single AR query on ReviewResponseMap with all associations - # eagerly loaded, serialized via as_json. Each row contains reviewer, - # reviewee, and full response/score content. + # Review rows — loads all ReviewResponseMaps for the assignment with reviewer, + # reviewee, responses, and scores eagerly loaded, then serializes via as_json. # - # ScoresReducer — streams submitted Responses to compute score percentages - # per reviewer × reviewee × round. + # ScoresReducer — streams submitted Response rows (one per reviewer × reviewee × + # round) and computes score percentage from the response's answers relative to + # the questionnaire's max score. # Output: { reviewer_id => { reviewee_id => { round => score_pct } } } # - # AvgRangesReducer — streams AssignmentTeam rows to compute the average - # review score per team. + # AvgRangesReducer — streams AssignmentTeam rows. For each team, the average + # review score is computed from its eagerly-loaded review_mappings and their + # nested responses and scores (via aggregate_review_grade). # Output: { team_id => avg_score } # # Output: From f69797bc53c04a64a1a9cd92549d49973cabea16 Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Mon, 15 Jun 2026 19:19:02 -0400 Subject: [PATCH 36/41] Refactor ScoresReducer to stream ResponseMap rows with eager loading - Add latest_submitted_response_by_round on ResponseMap: picks the most recent submitted response per round from already-loaded associations, avoiding N+1 queries - ScoresReducer now streams ReviewResponseMap rows instead of Response rows, using eager-loaded responses/scores/items for all computation - Remove hardcoded submitted_review_responses_for scope from Response - Fix base_reducer.rb indentation inside module Reports - Update comments to accurately reflect the new streaming approach Co-Authored-By: Claude Sonnet 4.6 --- app/models/reports/base_reducer.rb | 130 ++++++++++++++-------------- app/models/reports/review_report.rb | 32 +++---- app/models/response.rb | 14 +-- app/models/response_map.rb | 10 +++ 4 files changed, 92 insertions(+), 94 deletions(-) diff --git a/app/models/reports/base_reducer.rb b/app/models/reports/base_reducer.rb index 4f3f7a96d..e9eb34475 100644 --- a/app/models/reports/base_reducer.rb +++ b/app/models/reports/base_reducer.rb @@ -2,77 +2,77 @@ module Reports # Base class for streaming reducers. -# -# Design rationale (addresses two anti-patterns from the naive approach): -# -# Anti-pattern 1 — "fetch_responses": loading all records into an unnamed -# ad-hoc array before processing wastes memory and forces the entire result -# set into Ruby-land. Instead, #run streams the source relation via -# find_each so memory usage scales with the number of *groups*, not rows. -# -# Anti-pattern 2 — "default metrics in base": encoding avg_score or any -# domain metric in the base class ties every reducer to one shape of math. -# This class contains *only* the reducer scaffold; each subclass owns its -# accumulate/finalize logic entirely. -# -# Subclasses must implement (private): -# source → AR relation (consumed via find_each) -# initial_state → empty accumulator value -# accumulate(state, row) → mutates state in place; all grouping and domain -# math lives here, not in the base class. -# -# Subclasses may override (private): -# finalize(state) → transforms finished state into the output hash -# (default: returns state unchanged) -class BaseReducer - # Factory method for assignment-scoped reports. - def self.for_assignment(assignment) - new(assignment) - end + # + # Design rationale (addresses two anti-patterns from the naive approach): + # + # Anti-pattern 1 — "fetch_responses": loading all records into an unnamed + # ad-hoc array before processing wastes memory and forces the entire result + # set into Ruby-land. Instead, #run streams the source relation via + # find_each so memory usage scales with the number of *groups*, not rows. + # + # Anti-pattern 2 — "default metrics in base": encoding avg_score or any + # domain metric in the base class ties every reducer to one shape of math. + # This class contains *only* the reducer scaffold; each subclass owns its + # accumulate/finalize logic entirely. + # + # Subclasses must implement (private): + # source → AR relation (consumed via find_each) + # initial_state → empty accumulator value + # accumulate(state, row) → mutates state in place; all grouping and domain + # math lives here, not in the base class. + # + # Subclasses may override (private): + # finalize(state) → transforms finished state into the output hash + # (default: returns state unchanged) + class BaseReducer + # Factory method for assignment-scoped reports. + def self.for_assignment(assignment) + new(assignment) + end - # Factory method for course-scoped reports. - def self.for_course(course) - new(course) - end + # Factory method for course-scoped reports. + def self.for_course(course) + new(course) + end - # @param reportable [Assignment, Course] the object the report is scoped to. - # Subclasses reference @reportable instead of a type-specific variable so - # the same reducer works for any reportable entity. - def initialize(reportable) - @reportable = reportable - end + # @param reportable [Assignment, Course] the object the report is scoped to. + # Subclasses reference @reportable instead of a type-specific variable so + # the same reducer works for any reportable entity. + def initialize(reportable) + @reportable = reportable + end - # Runs the reducer: stream → accumulate → finalize. - # - # Accepts an optional shared_state so that multiple reducers can write - # into the same hash without a merge loop. When shared_state is provided, - # initial_state is ignored — the coordinator owns state initialization. - # finalize is always called, even when shared_state is provided. - # - # Benefits of this structure over writing reducer code directly: - # 1. Memory safety — find_each streams in batches of 500 rather than - # loading the entire relation into Ruby. Every report gets this for free. - # 2. New reports are just data — subclasses define source/initial_state/accumulate/finalize; - # the reducer wiring is not their concern. - # 3. Single place for cross-cutting concerns — logging, timing, or error - # handling can be added here once and applies to every report. - def run(shared_state = nil) - state = shared_state || initial_state - source.find_each(batch_size: 500) do |row| - accumulate(state, row) + # Runs the reducer: stream → accumulate → finalize. + # + # Accepts an optional shared_state so that multiple reducers can write + # into the same hash without a merge loop. When shared_state is provided, + # initial_state is ignored — the coordinator owns state initialization. + # finalize is always called, even when shared_state is provided. + # + # Benefits of this structure over writing reducer code directly: + # 1. Memory safety — find_each streams in batches of 500 rather than + # loading the entire relation into Ruby. Every report gets this for free. + # 2. New reports are just data — subclasses define source/initial_state/ + # accumulate/finalize; the reducer wiring is not their concern. + # 3. Single place for cross-cutting concerns — logging, timing, or error + # handling can be added here once and applies to every report. + def run(shared_state = nil) + state = shared_state || initial_state + source.find_each(batch_size: 500) do |row| + accumulate(state, row) + end + finalize(state) end - finalize(state) - end - private + private - def source = raise NotImplementedError, "#{self.class}#source" - def initial_state = raise NotImplementedError, "#{self.class}#initial_state" + def source = raise NotImplementedError, "#{self.class}#source" + def initial_state = raise NotImplementedError, "#{self.class}#initial_state" - def accumulate(_state, _row) - raise NotImplementedError, "#{self.class}#accumulate" - end + def accumulate(_state, _row) + raise NotImplementedError, "#{self.class}#accumulate" + end - def finalize(state) = state + def finalize(state) = state end -end +end \ No newline at end of file diff --git a/app/models/reports/review_report.rb b/app/models/reports/review_report.rb index b2229fdee..13ba0b4e8 100644 --- a/app/models/reports/review_report.rb +++ b/app/models/reports/review_report.rb @@ -9,9 +9,10 @@ module Reports # Review rows — loads all ReviewResponseMaps for the assignment with reviewer, # reviewee, responses, and scores eagerly loaded, then serializes via as_json. # - # ScoresReducer — streams submitted Response rows (one per reviewer × reviewee × - # round) and computes score percentage from the response's answers relative to - # the questionnaire's max score. + # ScoresReducer — streams ReviewResponseMap rows for the assignment. For each + # map, picks the latest submitted response per round (via + # latest_submitted_response_by_round) and computes a score percentage from + # the response's answers relative to the questionnaire's max score. # Output: { reviewer_id => { reviewee_id => { round => score_pct } } } # # AvgRangesReducer — streams AssignmentTeam rows. For each team, the average @@ -79,15 +80,16 @@ def run # ----------------------------------------------------------------------- # Reducer 1 — per-reviewer × reviewee × round score percentages. - # Streams submitted Responses with scores and questionnaires eagerly loaded - # to avoid N+1 inside calculate_total_score and maximum_score. + # Streams ReviewResponseMap rows with responses and scores eagerly loaded. + # For each map, delegates to latest_submitted_response_by_round to pick + # the most recent submitted response per round without N+1 queries. # Output: { reviewer_id => { reviewee_id => { round => score_pct } } } # ----------------------------------------------------------------------- class ScoresReducer < BaseReducer def source - Response - .submitted_review_responses_for(@reportable.id) - .includes(:response_map, scores: { item: :questionnaire }) + ReviewResponseMap + .for_assignment(@reportable.id) + .includes(responses: { scores: :item }) end def initial_state @@ -96,15 +98,13 @@ def initial_state end end - def accumulate(state, response) - return if response.maximum_score.zero? + def accumulate(state, map) + map.latest_submitted_response_by_round.each do |round, response| + next if response.maximum_score.zero? - reviewer_id = response.response_map.reviewer_id - reviewee_id = response.response_map.reviewee_id - round = response.round || 1 - score_pct = (response.aggregate_questionnaire_score.to_f / response.maximum_score * 100).round(2) - - state[reviewer_id][reviewee_id][round] = score_pct + score_pct = (response.aggregate_questionnaire_score.to_f / response.maximum_score * 100).round(2) + state[map.reviewer_id][map.reviewee_id][round] = score_pct + end end end diff --git a/app/models/response.rb b/app/models/response.rb index 59b07b4a3..492caf78f 100644 --- a/app/models/response.rb +++ b/app/models/response.rb @@ -6,19 +6,7 @@ class Response < ApplicationRecord belongs_to :response_map, class_name: 'ResponseMap', foreign_key: 'map_id', inverse_of: false - scope :submitted_review_responses_for, ->(assignment_id) { - # Subquery picks the latest submitted response per (response_map, round) - # so that only the most recent submission per reviewer-reviewee pair per round is counted. - latest_ids = joins(:response_map) - .where( - response_maps: { reviewed_object_id: assignment_id, type: 'ReviewResponseMap' }, - is_submitted: true - ) - .group('response_maps.id, responses.round') - .select('MAX(responses.id)') - - where(id: latest_ids) - } + has_many :scores, class_name: 'Answer', foreign_key: 'response_id', dependent: :destroy, inverse_of: false accepts_nested_attributes_for :scores diff --git a/app/models/response_map.rb b/app/models/response_map.rb index bac967bfb..4fb0e6df8 100644 --- a/app/models/response_map.rb +++ b/app/models/response_map.rb @@ -8,6 +8,16 @@ class ResponseMap < ApplicationRecord alias map_id id + # Returns the latest submitted response per round for this map. + # Relies on responses being eager-loaded (e.g. via includes) to avoid N+1. + # Output: { round => response } + def latest_submitted_response_by_round + responses + .select(&:is_submitted) + .group_by(&:round) + .transform_values { |rs| rs.max_by(&:id) } + end + # Shared helper for Response#rubric_label; looks up the declarative constant so each map advertises its UI label def response_map_label const_name = "#{self.class.name.demodulize.underscore.upcase}_TITLE" From ca392945de16bcd8913b98496ff071012696b633 Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Mon, 15 Jun 2026 19:22:36 -0400 Subject: [PATCH 37/41] Clarify ReviewReport output JSON structure in comments - Expand output schema comment with explicit types and annotations - Correct reviewee to be AssignmentTeam id (not a Participant with user) - Label score_pct and avg_score explicitly as percentages (0-100) - Annotate round as a 1-based integer Co-Authored-By: Claude Sonnet 4.6 --- app/models/reports/review_report.rb | 45 +++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/app/models/reports/review_report.rb b/app/models/reports/review_report.rb index 13ba0b4e8..5c47c5a06 100644 --- a/app/models/reports/review_report.rb +++ b/app/models/reports/review_report.rb @@ -22,15 +22,42 @@ module Reports # # Output: # { - # reviews: [ { id:, - # reviewer: { id:, user: { id:, name: } }, - # reviewee: { id:, user: { id:, name: } }, - # responses: [ { id:, round:, is_submitted:, additional_comment:, - # scores: [ { id:, answer:, comments:, - # item: { id:, txt:, weight: } } ] } ] - # }, ... ], - # scores: { reviewer_id => { reviewee_id => { round => score_pct } } }, - # avg_and_ranges: { team_id => avg_score } + # reviews: [ + # { + # id: Integer, # ReviewResponseMap id + # reviewer: { + # id: Integer, # AssignmentParticipant id + # user: { id: Integer, name: String } + # }, + # reviewee: { id: Integer }, # AssignmentTeam id + # responses: [ + # { + # id: Integer, + # round: Integer, # review round number (1-based) + # is_submitted: Boolean, + # additional_comment: String, + # scores: [ + # { + # id: Integer, + # answer: Integer, # raw score value + # comments: String, + # item: { id: Integer, txt: String, weight: Integer } + # } + # ] + # } + # ] + # } + # ], + # scores: { + # reviewer_id => { + # reviewee_id => { + # round => Float # score as a percentage (0–100) + # } + # } + # }, + # avg_and_ranges: { + # team_id => Float # average review score across all reviewers, as a percentage (0–100) + # } # } class ReviewReport # Whitelist for as_json — includes only fields the frontend needs, From 91d2fefad3f5842ebca2af03256261dd0426d5bb Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Mon, 15 Jun 2026 19:25:54 -0400 Subject: [PATCH 38/41] Revert schema.rb to main branch state Removes tag_prompts, tag_prompt_deployments, and answer_tags tables that were auto-generated from untracked migrations not belonging to this branch. Co-Authored-By: Claude Sonnet 4.6 --- db/schema.rb | 44 ++------------------------------------------ 1 file changed, 2 insertions(+), 42 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index 21016ea67..cddbe12c6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_05_18_000003) do +ActiveRecord::Schema[8.0].define(version: 2026_03_13_064334) do create_table "account_requests", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "username" t.string "full_name" @@ -25,19 +25,6 @@ t.index ["role_id"], name: "index_account_requests_on_role_id" end - create_table "answer_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "answer_id", null: false - t.bigint "tag_prompt_deployment_id", null: false - t.bigint "user_id", null: false - t.string "value", null: false - t.decimal "confidence_level", precision: 10, scale: 5 - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["answer_id"], name: "index_answer_tags_on_answer_id" - t.index ["tag_prompt_deployment_id"], name: "index_answer_tags_on_tag_prompt_deployment_id" - t.index ["user_id"], name: "index_answer_tags_on_user_id" - end - create_table "answers", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.integer "item_id", default: 0, null: false t.integer "response_id" @@ -401,27 +388,6 @@ t.index ["user_id"], name: "index_ta_mappings_on_user_id" end - create_table "tag_prompt_deployments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "tag_prompt_id", null: false - t.bigint "assignment_id", null: false - t.bigint "questionnaire_id", null: false - t.string "question_type" - t.integer "answer_length_threshold" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["assignment_id"], name: "index_tag_prompt_deployments_on_assignment_id" - t.index ["questionnaire_id"], name: "index_tag_prompt_deployments_on_questionnaire_id" - t.index ["tag_prompt_id"], name: "index_tag_prompt_deployments_on_tag_prompt_id" - end - - create_table "tag_prompts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.string "prompt", null: false - t.string "desc", null: false - t.string "control_type", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - create_table "teams", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -484,18 +450,15 @@ add_foreign_key "account_requests", "institutions" add_foreign_key "account_requests", "roles" - add_foreign_key "answer_tags", "answers" - add_foreign_key "answer_tags", "tag_prompt_deployments" - add_foreign_key "answer_tags", "users" add_foreign_key "assignments", "courses" add_foreign_key "assignments", "users", column: "instructor_id" add_foreign_key "assignments_duties", "assignments" add_foreign_key "assignments_duties", "duties" add_foreign_key "courses", "institutions" add_foreign_key "courses", "users", column: "instructor_id" - add_foreign_key "duties", "users", column: "instructor_id" add_foreign_key "invitations", "participants", column: "from_id" add_foreign_key "invitations", "participants", column: "to_id" + add_foreign_key "duties", "users", column: "instructor_id" add_foreign_key "items", "questionnaires" add_foreign_key "participants", "duties" add_foreign_key "participants", "join_team_requests" @@ -508,9 +471,6 @@ add_foreign_key "signed_up_teams", "teams" add_foreign_key "ta_mappings", "courses" add_foreign_key "ta_mappings", "users" - add_foreign_key "tag_prompt_deployments", "assignments" - add_foreign_key "tag_prompt_deployments", "questionnaires" - add_foreign_key "tag_prompt_deployments", "tag_prompts" add_foreign_key "teams_participants", "participants" add_foreign_key "teams_participants", "teams" add_foreign_key "teams_users", "teams" From 73cbbdf71e841ebf9eace4a78df885e850f3486c Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Mon, 15 Jun 2026 19:26:35 -0400 Subject: [PATCH 39/41] Revert seeds.rb to main branch state Review report seed data is local-only and not needed in the branch. Co-Authored-By: Claude Sonnet 4.6 --- db/seeds.rb | 95 ----------------------------------------------------- 1 file changed, 95 deletions(-) diff --git a/db/seeds.rb b/db/seeds.rb index e5a34eebb..d49c80f33 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -138,98 +138,3 @@ rescue ActiveRecord::RecordInvalid => e puts e, 'The db has already been seeded' end - -# --------------------------------------------------------------------------- -# Review report seed data -# Creates the questionnaire, questions, response maps, responses, and answers -# needed to exercise the ReviewReport for the first assignment. -# --------------------------------------------------------------------------- -begin - assignment = Assignment.first - instructor = User.find_by(role_id: 3) - - puts "creating review questionnaire" - questionnaire = Questionnaire.create!( - name: 'Peer Review Rubric', - instructor_id: instructor.id, - private: false, - min_question_score: 0, - max_question_score: 5, - questionnaire_type: 'ReviewQuestionnaire' - ) - - puts "creating questionnaire items" - items = [ - 'How well did the team communicate?', - 'How thoroughly were the deliverables completed?', - 'Rate the overall quality of the work.' - ].each_with_index.map do |txt, i| - Item.create!( - questionnaire_id: questionnaire.id, - txt: txt, - weight: 1, - seq: i + 1, - question_type: 'Criterion', - break_before: true - ) - end - - puts "linking questionnaire to assignment" - AssignmentQuestionnaire.find_or_create_by!( - assignment_id: assignment.id, - questionnaire_id: questionnaire.id, - used_in_round: 1 - ) { |aq| aq.questionnaire_weight = 100; aq.notification_limit = 15 } - - AssignmentQuestionnaire.find_or_create_by!( - assignment_id: assignment.id, - questionnaire_id: questionnaire.id, - used_in_round: 2 - ) { |aq| aq.questionnaire_weight = 100; aq.notification_limit = 15 } - - teams = AssignmentTeam.where(parent_id: assignment.id).to_a - participants = AssignmentParticipant.where(parent_id: assignment.id).to_a - - puts "creating review response maps, responses, and answers" - participants.each_with_index do |reviewer, idx| - reviewee_team = teams.reject { |t| t.id == reviewer.team_id }.first - next if reviewee_team.nil? - - # One map per reviewer-reviewee pair — idempotent - map = ReviewResponseMap.find_or_create_by!( - reviewed_object_id: assignment.id, - reviewer_id: reviewer.id, - reviewee_id: reviewee_team.id - ) - - # Round 1 — all reviewers - r1 = Response.find_or_create_by!(map_id: map.id, round: 1) do |r| - r.is_submitted = true - r.additional_comment = Faker::Lorem.sentence - end - items.each do |item| - Answer.find_or_create_by!(response_id: r1.id, item_id: item.id) do |a| - a.answer = rand(3..5) - a.comments = Faker::Lorem.sentence - end - end - - # Round 2 — first reviewer only, so scores vs avg_and_ranges differ visibly - next unless idx.zero? - - r2 = Response.find_or_create_by!(map_id: map.id, round: 2) do |r| - r.is_submitted = true - r.additional_comment = Faker::Lorem.sentence - end - items.each do |item| - Answer.find_or_create_by!(response_id: r2.id, item_id: item.id) do |a| - a.answer = rand(1..3) - a.comments = Faker::Lorem.sentence - end - end - end - - puts "Review report seed data created successfully." -rescue ActiveRecord::RecordInvalid => e - puts e, 'Review report seed data may already exist.' -end From 55c60c0ece458e49d14a36df13481236e2def1ae Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Mon, 15 Jun 2026 19:31:43 -0400 Subject: [PATCH 40/41] Remove extra blank lines in Response model Co-Authored-By: Claude Sonnet 4.6 --- app/models/response.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/models/response.rb b/app/models/response.rb index 492caf78f..e8ed2d7ba 100644 --- a/app/models/response.rb +++ b/app/models/response.rb @@ -5,8 +5,6 @@ class Response < ApplicationRecord include MetricHelper belongs_to :response_map, class_name: 'ResponseMap', foreign_key: 'map_id', inverse_of: false - - has_many :scores, class_name: 'Answer', foreign_key: 'response_id', dependent: :destroy, inverse_of: false accepts_nested_attributes_for :scores From 284246ff5f024c74c58c388f73fe7f8a932307ce Mon Sep 17 00:00:00 2001 From: aanand-1706 Date: Mon, 15 Jun 2026 19:37:09 -0400 Subject: [PATCH 41/41] =?UTF-8?q?Rename=20output=20keys:=20scores=E2=86=92?= =?UTF-8?q?reviewer=5Fscores,=20avg=5Fand=5Franges=E2=86=92team=5Faverages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scores` was ambiguous (clashes with raw answer scores inside reviews[]). `avg_and_ranges` was a holdover from the original Expertiza that computed ranges — the current output is averages only, so team_averages is accurate. Co-Authored-By: Claude Sonnet 4.6 --- app/models/reports/review_report.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/models/reports/review_report.rb b/app/models/reports/review_report.rb index 5c47c5a06..f97b98512 100644 --- a/app/models/reports/review_report.rb +++ b/app/models/reports/review_report.rb @@ -48,14 +48,14 @@ module Reports # ] # } # ], - # scores: { + # reviewer_scores: { # reviewer_id => { # reviewee_id => { # round => Float # score as a percentage (0–100) # } # } # }, - # avg_and_ranges: { + # team_averages: { # team_id => Float # average review score across all reviewers, as a percentage (0–100) # } # } @@ -99,9 +99,9 @@ def run .includes(reviewer: :user, reviewee: :user, responses: { scores: :item }) { - reviews: maps.as_json(MAP_JSON_OPTIONS), - scores: ScoresReducer.new(@reportable).run, - avg_and_ranges: AvgRangesReducer.new(@reportable).run + reviews: maps.as_json(MAP_JSON_OPTIONS), + reviewer_scores: ScoresReducer.new(@reportable).run, + team_averages: AvgRangesReducer.new(@reportable).run } end