<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
	<id>https://wiki.expertiza.ncsu.edu/api.php?action=feedcontributions&amp;feedformat=atom&amp;user=Blalu</id>
	<title>Expertiza_Wiki - User contributions [en]</title>
	<link rel="self" type="application/atom+xml" href="https://wiki.expertiza.ncsu.edu/api.php?action=feedcontributions&amp;feedformat=atom&amp;user=Blalu"/>
	<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Special:Contributions/Blalu"/>
	<updated>2026-11-05T01:17:32Z</updated>
	<subtitle>User contributions</subtitle>
	<generator>MediaWiki 1.41.0</generator>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Report_Generation_Framework_(WIP)&amp;diff=168331</id>
		<title>Report Generation Framework (WIP)</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Report_Generation_Framework_(WIP)&amp;diff=168331"/>
		<updated>2026-08-27T13:50:24Z</updated>

		<summary type="html">&lt;p&gt;Blalu: Blalu moved page Report Generation Framework (WIP) to Reports and Course Summary: Misspelled title&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;#REDIRECT [[Reports and Course Summary]]&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168330</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168330"/>
		<updated>2026-08-27T13:50:24Z</updated>

		<summary type="html">&lt;p&gt;Blalu: Blalu moved page Report Generation Framework (WIP) to Reports and Course Summary: Misspelled title&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Reports and Course Summary =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture and Renders ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== Assignments ===&lt;br /&gt;
&lt;br /&gt;
Few changes have been done to the assignment pages to include the reports and instructor grading scales.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Manage_Assignments_tab.png|This is the Assignments tab for an instructor/admin.&lt;br /&gt;
File:Assignment_Review_Strategy_tab.png|Review the strategy tab for an assignment where min and max scores for the reviews can be set.&lt;br /&gt;
File:Assignment_Etc_tab.png|The implemented reports have been listed in the View Reports button.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Review_Report_1.png|The Review Report page showing reviewer scores and reviewer grade for an assignment with a collapsible legend.&lt;br /&gt;
File:Review_Report_2.png|The Review Report page (with sticky headers) showing reviewer scores and visual metrics comparing the reviewer and average.&lt;br /&gt;
File:Review_Report_Summary_page.png|The Review Report page summary button for a reviewer redirects to a new tab with the reviews done segregated by team and round with heatgrid styling enabled.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Teammate_Review_Report_1.png|The Teammate Review Report page showing the status of reviews required by teammates about each other.&lt;br /&gt;
File: Teammate_Review_Report_2.png|Detailed teammate reviews page redirected from the previous Teammate Review Report page view button&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Courses ===&lt;br /&gt;
&lt;br /&gt;
Few changes have been done to the courses page to redirect to the new course reports. Two new buttons have been made for that. Each assignment in a course has a button to redirect to the ReviewReport page of that assignment.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File: Manage_Courses_tab.png |This is the courses tab accessed by instructors/admin.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Course_Reports_Grade_Summary.png|A horizontal scroll and heatstyle enabled table which shows detailed grade distribution for each students in the course per assignment.&lt;br /&gt;
File:Course_Reports_Teammate_Reviews_Summary.png|A horizontal scroll and heatstyle enabled table which shows detailed teammate reviews given and received by each teammate per assignment.&lt;br /&gt;
File:Review_Report_Summary_page.png|The Review Report page summary button for a reviewer redirects to a new tab with the reviews done segregated by team and round with heatgrid styling enabled.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168329</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168329"/>
		<updated>2026-08-27T13:46:51Z</updated>

		<summary type="html">&lt;p&gt;Blalu: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Reports and Course Summary =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture and Renders ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== Assignments ===&lt;br /&gt;
&lt;br /&gt;
Few changes have been done to the assignment pages to include the reports and instructor grading scales.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Manage_Assignments_tab.png|This is the Assignments tab for an instructor/admin.&lt;br /&gt;
File:Assignment_Review_Strategy_tab.png|Review the strategy tab for an assignment where min and max scores for the reviews can be set.&lt;br /&gt;
File:Assignment_Etc_tab.png|The implemented reports have been listed in the View Reports button.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Review_Report_1.png|The Review Report page showing reviewer scores and reviewer grade for an assignment with a collapsible legend.&lt;br /&gt;
File:Review_Report_2.png|The Review Report page (with sticky headers) showing reviewer scores and visual metrics comparing the reviewer and average.&lt;br /&gt;
File:Review_Report_Summary_page.png|The Review Report page summary button for a reviewer redirects to a new tab with the reviews done segregated by team and round with heatgrid styling enabled.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Teammate_Review_Report_1.png|The Teammate Review Report page showing the status of reviews required by teammates about each other.&lt;br /&gt;
File: Teammate_Review_Report_2.png|Detailed teammate reviews page redirected from the previous Teammate Review Report page view button&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Courses ===&lt;br /&gt;
&lt;br /&gt;
Few changes have been done to the courses page to redirect to the new course reports. Two new buttons have been made for that. Each assignment in a course has a button to redirect to the ReviewReport page of that assignment.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File: Manage_Courses_tab.png |This is the courses tab accessed by instructors/admin.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Course_Reports_Grade_Summary.png|A horizontal scroll and heatstyle enabled table which shows detailed grade distribution for each students in the course per assignment.&lt;br /&gt;
File:Course_Reports_Teammate_Reviews_Summary.png|A horizontal scroll and heatstyle enabled table which shows detailed teammate reviews given and received by each teammate per assignment.&lt;br /&gt;
File:Review_Report_Summary_page.png|The Review Report page summary button for a reviewer redirects to a new tab with the reviews done segregated by team and round with heatgrid styling enabled.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168328</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168328"/>
		<updated>2026-08-26T19:27:05Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* Courses */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture and Renders ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== Assignments ===&lt;br /&gt;
&lt;br /&gt;
Few changes have been done to the assignment pages to include the reports and instructor grading scales.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Manage_Assignments_tab.png|This is the Assignments tab for an instructor/admin.&lt;br /&gt;
File:Assignment_Review_Strategy_tab.png|Review the strategy tab for an assignment where min and max scores for the reviews can be set.&lt;br /&gt;
File:Assignment_Etc_tab.png|The implemented reports have been listed in the View Reports button.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Review_Report_1.png|The Review Report page showing reviewer scores and reviewer grade for an assignment with a collapsible legend.&lt;br /&gt;
File:Review_Report_2.png|The Review Report page (with sticky headers) showing reviewer scores and visual metrics comparing the reviewer and average.&lt;br /&gt;
File:Review_Report_Summary_page.png|The Review Report page summary button for a reviewer redirects to a new tab with the reviews done segregated by team and round with heatgrid styling enabled.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Teammate_Review_Report_1.png|The Teammate Review Report page showing the status of reviews required by teammates about each other.&lt;br /&gt;
File: Teammate_Review_Report_2.png|Detailed teammate reviews page redirected from the previous Teammate Review Report page view button&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Courses ===&lt;br /&gt;
&lt;br /&gt;
Few changes have been done to the courses page to redirect to the new course reports. Two new buttons have been made for that. Each assignment in a course has a button to redirect to the ReviewReport page of that assignment.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File: Manage_Courses_tab.png |This is the courses tab accessed by instructors/admin.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Course_Reports_Grade_Summary.png|A horizontal scroll and heatstyle enabled table which shows detailed grade distribution for each students in the course per assignment.&lt;br /&gt;
File:Course_Reports_Teammate_Reviews_Summary.png|A horizontal scroll and heatstyle enabled table which shows detailed teammate reviews given and received by each teammate per assignment.&lt;br /&gt;
File:Review_Report_Summary_page.png|The Review Report page summary button for a reviewer redirects to a new tab with the reviews done segregated by team and round with heatgrid styling enabled.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168327</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168327"/>
		<updated>2026-08-26T19:26:08Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* Frontend Architecture and Renders */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture and Renders ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== Assignments ===&lt;br /&gt;
&lt;br /&gt;
Few changes have been done to the assignment pages to include the reports and instructor grading scales.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Manage_Assignments_tab.png|This is the Assignments tab for an instructor/admin.&lt;br /&gt;
File:Assignment_Review_Strategy_tab.png|Review the strategy tab for an assignment where min and max scores for the reviews can be set.&lt;br /&gt;
File:Assignment_Etc_tab.png|The implemented reports have been listed in the View Reports button.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Review_Report_1.png|The Review Report page showing reviewer scores and reviewer grade for an assignment with a collapsible legend.&lt;br /&gt;
File:Review_Report_2.png|The Review Report page (with sticky headers) showing reviewer scores and visual metrics comparing the reviewer and average.&lt;br /&gt;
File:Review_Report_Summary_page.png|The Review Report page summary button for a reviewer redirects to a new tab with the reviews done segregated by team and round with heatgrid styling enabled.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Teammate_Review_Report_1.png|The Teammate Review Report page showing the status of reviews required by teammates about each other.&lt;br /&gt;
File: Teammate_Review_Report_2.png|Detailed teammate reviews page redirected from the previous Teammate Review Report page view button&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Courses ===&lt;br /&gt;
&lt;br /&gt;
Few changes have been done to the courses page to redirect to the new course reports. Two new buttons have been made for that. Each assignment in a course has a button to redirect to the ReviewReport page of that assignment.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File: Manage_Courses_tab.png |This is the courses tab accessed by instructors/admin.&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Course_Reports_Grade_Summary.png|A horizontal scroll and heatstyle enabled table which shows detailed grade distribution for each students in the course per assignment.&lt;br /&gt;
File:Course_Reports_Teammate_Reviews_Summary.png|A horizontal scroll and heatstyle enabled table which shows detailed teammate reviews given and received by each teammate per assignment.&lt;br /&gt;
File:Review_Report_Summary_page.png|The Review Report page summary button for a reviewer redirects to a new tab with the reviews done segregated by team and round with heatgrid styling enabled.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168326</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168326"/>
		<updated>2026-08-26T19:21:02Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* Courses */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture and Renders ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== Assignments ===&lt;br /&gt;
&lt;br /&gt;
Few changes have been done to the assignment pages to include the reports and instructor grading scales.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Manage_Assignments_tab.png|This is the Assignments tab for an instructor/admin.&lt;br /&gt;
File:Assignment_Review_Strategy_tab.png|Review the strategy tab for an assignment where min and max scores for the reviews can be set.&lt;br /&gt;
File:Assignment_Etc_tab.png|The implemented reports have been listed in the View Reports button.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Review_Report_1.png|The Review Report page showing reviewer scores and reviewer grade for an assignment with a collapsible legend.&lt;br /&gt;
File:Review_Report_2.png|The Review Report page (with sticky headers) showing reviewer scores and visual metrics comparing the reviewer and average.&lt;br /&gt;
File:Review_Report_Summary_page.png|The Review Report page summary button for a reviewer redirects to a new tab with the reviews done segregated by team and round with heatgrid styling enabled.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Teammate_Review_Report_1.png|The Teammate Review Report page showing the status of reviews required by teammates about each other.&lt;br /&gt;
File: Teammate_Review_Report_2.png|Detailed teammate reviews page redirected from the previous Teammate Review Report page view button&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Courses ===&lt;br /&gt;
&lt;br /&gt;
Few changes have been done to the courses page to redirect to the new course reports.&lt;br /&gt;
&lt;br /&gt;
Manage_Courses_tab.png&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Course_Reports_Grade_Summary.png|A horizontal scroll and heatstyle enabled table which shows detailed grade distribution for each students in the course per assignment.&lt;br /&gt;
File:Course_Reports_Teammate_Reviews_Summary.png|A horizontal scroll and heatstyle enabled table which shows detailed teammate reviews given and received by each teammate per assignment.&lt;br /&gt;
File:Review_Report_Summary_page.png|The Review Report page summary button for a reviewer redirects to a new tab with the reviews done segregated by team and round with heatgrid styling enabled.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=File:Manage_Courses_tab.png&amp;diff=168325</id>
		<title>File:Manage Courses tab.png</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=File:Manage_Courses_tab.png&amp;diff=168325"/>
		<updated>2026-08-26T19:20:36Z</updated>

		<summary type="html">&lt;p&gt;Blalu: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168324</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168324"/>
		<updated>2026-08-26T16:48:36Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* Frontend Architecture and Renders */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture and Renders ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== Assignments ===&lt;br /&gt;
&lt;br /&gt;
Few changes have been done to the assignment pages to include the reports and instructor grading scales.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Manage_Assignments_tab.png|This is the Assignments tab for an instructor/admin.&lt;br /&gt;
File:Assignment_Review_Strategy_tab.png|Review the strategy tab for an assignment where min and max scores for the reviews can be set.&lt;br /&gt;
File:Assignment_Etc_tab.png|The implemented reports have been listed in the View Reports button.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Review_Report_1.png|The Review Report page showing reviewer scores and reviewer grade for an assignment with a collapsible legend.&lt;br /&gt;
File:Review_Report_2.png|The Review Report page (with sticky headers) showing reviewer scores and visual metrics comparing the reviewer and average.&lt;br /&gt;
File:Review_Report_Summary_page.png|The Review Report page summary button for a reviewer redirects to a new tab with the reviews done segregated by team and round with heatgrid styling enabled.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Teammate_Review_Report_1.png|The Teammate Review Report page showing the status of reviews required by teammates about each other.&lt;br /&gt;
File: Teammate_Review_Report_2.png|Detailed teammate reviews page redirected from the previous Teammate Review Report page view button&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Courses ===&lt;br /&gt;
&lt;br /&gt;
Few changes have been done to the courses page to redirect to the new course reports.&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Course_Reports_Grade_Summary.png|A horizontal scroll and heatstyle enabled table which shows detailed grade distribution for each students in the course per assignment.&lt;br /&gt;
File:Course_Reports_Teammate_Reviews_Summary.png|A horizontal scroll and heatstyle enabled table which shows detailed teammate reviews given and received by each teammate per assignment.&lt;br /&gt;
File:Review_Report_Summary_page.png|The Review Report page summary button for a reviewer redirects to a new tab with the reviews done segregated by team and round with heatgrid styling enabled.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=File:Course_Reports_Teammate_Reviews_Summary.png&amp;diff=168323</id>
		<title>File:Course Reports Teammate Reviews Summary.png</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=File:Course_Reports_Teammate_Reviews_Summary.png&amp;diff=168323"/>
		<updated>2026-08-26T16:45:29Z</updated>

		<summary type="html">&lt;p&gt;Blalu: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=File:Course_Reports_Grade_Summary.png&amp;diff=168322</id>
		<title>File:Course Reports Grade Summary.png</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=File:Course_Reports_Grade_Summary.png&amp;diff=168322"/>
		<updated>2026-08-26T16:43:07Z</updated>

		<summary type="html">&lt;p&gt;Blalu: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168321</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168321"/>
		<updated>2026-08-26T16:40:11Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* Frontend Architecture and Renders */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture and Renders ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== Assignments ===&lt;br /&gt;
&lt;br /&gt;
Few changes have been done to the Assignment pages to include the reports and instructor grading scales.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Manage_Assignments_tab.png|This is the Assignments tab for an instructor/admin.&lt;br /&gt;
File:Assignment_Review_Strategy_tab.png|Review the strategy tab for an assignment where min and max scores for the reviews can be set.&lt;br /&gt;
File:Assignment_Etc_tab.png|The implemented reports have been listed in the View Reports button.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Review_Report_1.png|The Review Report page showing reviewer scores and reviewer grade for an assignment with a collapsible legend.&lt;br /&gt;
File:Review_Report_2.png|The Review Report page (with sticky headers) showing reviewer scores and visual metrics comparing the reviewer and average.&lt;br /&gt;
File:Review_Report_Summary_page.png|The Review Report page summary button for a reviewer redirects to a new tab with the reviews done segregated by team and round with heatgrid styling enabled.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Teammate_Review_Report_1.png|The Teammate Review Report page showing the status of reviews required by teammates about each other.&lt;br /&gt;
File: Teammate_Review_Report_2.png|Detailed teammate reviews page redirected from the previous Teammate Review Report page view button&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168320</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168320"/>
		<updated>2026-08-26T16:38:15Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* Frontend Architecture */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture and Renders ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== Assignments ===&lt;br /&gt;
&lt;br /&gt;
Few changes have been done to the Assignment pages to include the reports and instructor grading scales.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Manage_Assignments_tab.png|This is the Assignments tab for an instructor/admin.&lt;br /&gt;
File:Assignment_Review_Strategy_tab.png|Review the strategy tab for an assignment where min and max scores for the reviews can be set.&lt;br /&gt;
File:Assignment_Etc_tab.png|The implemented reports have been listed in the View Reports button.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Review_Report_1.png|The Review Report page showing reviewer scores and reviewer grade for an assignment with a collapsible legend.&lt;br /&gt;
File:Review_Report_2.png|The Review Report page (with sticky headers) showing reviewer scores and visual metrics comparing the reviewer and average.&lt;br /&gt;
File:Review_Report_Summary_page.png|The Review Report page summary button for a reviewer redirects to a new tab with the reviews done segregated by team and round with heatgrid styling enabled.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Teammate_Review_Report_1.png|The Teammate Review Report page showing the status of reviews required by teammates about each other.&lt;br /&gt;
File: Teammate_Review_Report_2.png|Detailed teammate reviews page redirected from the previous Teammate Review Report page view button&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=File:Teammate_Review_Report_2.png&amp;diff=168319</id>
		<title>File:Teammate Review Report 2.png</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=File:Teammate_Review_Report_2.png&amp;diff=168319"/>
		<updated>2026-08-26T16:36:53Z</updated>

		<summary type="html">&lt;p&gt;Blalu: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=File:Teammate_Review_Report_1.png&amp;diff=168318</id>
		<title>File:Teammate Review Report 1.png</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=File:Teammate_Review_Report_1.png&amp;diff=168318"/>
		<updated>2026-08-26T16:34:46Z</updated>

		<summary type="html">&lt;p&gt;Blalu: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168317</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168317"/>
		<updated>2026-08-26T16:28:00Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* ReviewReportPage */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Manage_Assignments_tab.png|This is the Assignments tab for an instructor/admin.&lt;br /&gt;
File:Assignment_Review_Strategy_tab.png|Review the strategy tab for an assignment where min and max scores for the reviews can be set.&lt;br /&gt;
File:Assignment_Etc_tab.png|The implemented reports have been listed in the View Reports button.&lt;br /&gt;
File:Review_Report_1.png|The Review Report page showing reviewer scores and reviewer grade for an assignment with a collapsible legend.&lt;br /&gt;
File:Review_Report_2.png|The Review Report page (with sticky headers) showing reviewer scores and visual metrics comparing the reviewer and average.&lt;br /&gt;
File:Review_Report_Summary_page.png|The Review Report page summary button for a reviewer redirects to a new tab with the reviews done segregated by team and round with heatgrid styling enabled.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=File:Review_Report_Summary_page.png&amp;diff=168316</id>
		<title>File:Review Report Summary page.png</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=File:Review_Report_Summary_page.png&amp;diff=168316"/>
		<updated>2026-08-26T16:25:51Z</updated>

		<summary type="html">&lt;p&gt;Blalu: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168315</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168315"/>
		<updated>2026-08-26T16:23:35Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* ReviewReportPage */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Manage_Assignments_tab.png|This is the Assignments tab for an instructor/admin.&lt;br /&gt;
File:Assignment_Review_Strategy_tab.png|Review the strategy tab for an assignment where min and max scores for the reviews can be set.&lt;br /&gt;
File:Assignment_Etc_tab.png|The implemented reports have been listed in the View Reports button.&lt;br /&gt;
File:Review_Report_1.png|The Review Report page showing reviewer scores and reviewer grade for an assignment with a collapsible legend.&lt;br /&gt;
File:Review_Report_2.png|The Review Report page(with sticky headers) showing reviewer scores and visual metrics comparing the reviewer and average.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=File:Review_Report_2.png&amp;diff=168314</id>
		<title>File:Review Report 2.png</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=File:Review_Report_2.png&amp;diff=168314"/>
		<updated>2026-08-26T16:20:57Z</updated>

		<summary type="html">&lt;p&gt;Blalu: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168313</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168313"/>
		<updated>2026-08-26T16:18:19Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* ReviewReportPage */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Manage_Assignments_tab.png|This is the Assignments tab for an instructor/admin.&lt;br /&gt;
File:Assignment_Review_Strategy_tab.png|Review the strategy tab for an assignment where min and max score for the reviews can be set.&lt;br /&gt;
File:Assignment_Etc_tab.png|The implemented reports have been listed in the View Reports button.&lt;br /&gt;
File:Review_Report_1.png|The Review Report page showing reviewer scores and grade assignment.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|left|x350px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
[[File:Assignment_Review_Strategy_tab.png|thumb|right|x350px|Review the strategy tab for an assignment where min and max score for the reviews can be set.]]&lt;br /&gt;
[[File:Assignment_Etc_tab.png|thumb|none|x350px|The implemented reports have been listed in the View Reports button.]]&lt;br /&gt;
[[File:Review_Report_1.png|thumb|none|x350px|Review Report page with collapsible legend.]]&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168312</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168312"/>
		<updated>2026-08-26T16:17:19Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* ReviewReportPage */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;traditional&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Manage_Assignments_tab.png|This is the Assignments tab for an instructor/admin.&lt;br /&gt;
File:Assignment_Review_Strategy_tab.png|Review the strategy tab for an assignment where min and max score for the reviews can be set.&lt;br /&gt;
File:Assignment_Etc_tab.png|The implemented reports have been listed in the View Reports button.&lt;br /&gt;
File:Review_Report_1.png|The Review Report page showing reviewer scores and grade assignment.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|left|x350px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
[[File:Assignment_Review_Strategy_tab.png|thumb|right|x350px|Review the strategy tab for an assignment where min and max score for the reviews can be set.]]&lt;br /&gt;
[[File:Assignment_Etc_tab.png|thumb|none|x350px|The implemented reports have been listed in the View Reports button.]]&lt;br /&gt;
[[File:Review_Report_1.png|thumb|none|x350px|Review Report page with collapsible legend.]]&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168311</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168311"/>
		<updated>2026-08-26T16:16:21Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* ReviewReportPage */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
{| style=&amp;quot;width:100%;&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
| style=&amp;quot;width:50%; vertical-align:top; text-align:center;&amp;quot; |&lt;br /&gt;
[[File:Manage_Assignments_tab.png|frameless|center|400px]]&lt;br /&gt;
&amp;lt;div style=&amp;quot;font-size:0.9em; text-align:center; margin-top:4px;&amp;quot;&amp;gt;This is the Assignments tab for an instructor/admin.&amp;lt;/div&amp;gt;&lt;br /&gt;
| style=&amp;quot;width:50%; vertical-align:top; text-align:center;&amp;quot; |&lt;br /&gt;
[[File:Assignment_Review_Strategy_tab.png|frameless|center|400px]]&lt;br /&gt;
&amp;lt;div style=&amp;quot;font-size:0.9em; text-align:center; margin-top:4px;&amp;quot;&amp;gt;Review the strategy tab for an assignment where min and max score for the reviews can be set.&amp;lt;/div&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| style=&amp;quot;width:50%; vertical-align:top; text-align:center;&amp;quot; |&lt;br /&gt;
[[File:Assignment_Etc_tab.png|frameless|center|400px]]&lt;br /&gt;
&amp;lt;div style=&amp;quot;font-size:0.9em; text-align:center; margin-top:4px;&amp;quot;&amp;gt;The implemented reports have been listed in the View Reports button.&amp;lt;/div&amp;gt;&lt;br /&gt;
| style=&amp;quot;width:50%; vertical-align:top; text-align:center;&amp;quot; |&lt;br /&gt;
[[File:Review_Report_1.png|frameless|center|400px]]&lt;br /&gt;
&amp;lt;div style=&amp;quot;font-size:0.9em; text-align:center; margin-top:4px;&amp;quot;&amp;gt;The Review Report page showing reviewer scores and grade assignment.&amp;lt;/div&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery mode=&amp;quot;nolines&amp;quot; widths=&amp;quot;500px&amp;quot; heights=&amp;quot;350px&amp;quot; perrow=&amp;quot;2&amp;quot;&amp;gt;&lt;br /&gt;
File:Manage_Assignments_tab.png|This is the Assignments tab for an instructor/admin.&lt;br /&gt;
File:Assignment_Review_Strategy_tab.png|Review the strategy tab for an assignment where min and max score for the reviews can be set.&lt;br /&gt;
File:Assignment_Etc_tab.png|The implemented reports have been listed in the View Reports button.&lt;br /&gt;
File:Review_Report_1.png|The Review Report page showing reviewer scores and grade assignment.&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|left|x350px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
[[File:Assignment_Review_Strategy_tab.png|thumb|right|x350px|Review the strategy tab for an assignment where min and max score for the reviews can be set.]]&lt;br /&gt;
[[File:Assignment_Etc_tab.png|thumb|none|x350px|The implemented reports have been listed in the View Reports button.]]&lt;br /&gt;
[[File:Review_Report_1.png|thumb|none|x350px|Review Report page with collapsible legend.]]&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168310</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168310"/>
		<updated>2026-08-26T16:14:42Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* ReviewReportPage */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
{| style=&amp;quot;width:100%;&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
| style=&amp;quot;width:50%; vertical-align:top; text-align:center;&amp;quot; |&lt;br /&gt;
[[File:Manage_Assignments_tab.png|frameless|center|400px]]&lt;br /&gt;
&amp;lt;div style=&amp;quot;font-size:0.9em; text-align:center; margin-top:4px;&amp;quot;&amp;gt;This is the Assignments tab for an instructor/admin.&amp;lt;/div&amp;gt;&lt;br /&gt;
| style=&amp;quot;width:50%; vertical-align:top; text-align:center;&amp;quot; |&lt;br /&gt;
[[File:Assignment_Review_Strategy_tab.png|frameless|center|400px]]&lt;br /&gt;
&amp;lt;div style=&amp;quot;font-size:0.9em; text-align:center; margin-top:4px;&amp;quot;&amp;gt;Review the strategy tab for an assignment where min and max score for the reviews can be set.&amp;lt;/div&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| style=&amp;quot;width:50%; vertical-align:top; text-align:center;&amp;quot; |&lt;br /&gt;
[[File:Assignment_Etc_tab.png|frameless|center|400px]]&lt;br /&gt;
&amp;lt;div style=&amp;quot;font-size:0.9em; text-align:center; margin-top:4px;&amp;quot;&amp;gt;The implemented reports have been listed in the View Reports button.&amp;lt;/div&amp;gt;&lt;br /&gt;
| style=&amp;quot;width:50%; vertical-align:top; text-align:center;&amp;quot; |&lt;br /&gt;
[[File:Review_Report_1.png|frameless|center|400px]]&lt;br /&gt;
&amp;lt;div style=&amp;quot;font-size:0.9em; text-align:center; margin-top:4px;&amp;quot;&amp;gt;The Review Report page showing reviewer scores and grade assignment.&amp;lt;/div&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|left|x350px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
[[File:Assignment_Review_Strategy_tab.png|thumb|right|x350px|Review the strategy tab for an assignment where min and max score for the reviews can be set.]]&lt;br /&gt;
[[File:Assignment_Etc_tab.png|thumb|none|x350px|The implemented reports have been listed in the View Reports button.]]&lt;br /&gt;
[[File:Review_Report_1.png|thumb|none|x350px|Review Report page with collapsible legend.]]&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168309</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168309"/>
		<updated>2026-08-26T16:13:30Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* ReviewReportPage */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
{| style=&amp;quot;width:100%; border-spacing:0px;&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
| style=&amp;quot;width:50%; vertical-align:top;&amp;quot; |&lt;br /&gt;
[[File:Manage_Assignments_tab.png |thumb|center|400px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
| style=&amp;quot;width:50%; vertical-align:top;&amp;quot; |&lt;br /&gt;
[[File:Assignment_Review_Strategy_tab.png|thumb|center|400px|Review the strategy tab for an assignment where min and max score for the reviews can be set.]]&lt;br /&gt;
|-&lt;br /&gt;
| style=&amp;quot;width:50%; vertical-align:top;&amp;quot; |&lt;br /&gt;
[[File:Assignment_Etc_tab.png|thumb|center|400px|The implemented reports have been listed in the View Reports button.]]&lt;br /&gt;
| style=&amp;quot;width:50%; vertical-align:top;&amp;quot; |&lt;br /&gt;
[[File:Review_Report_1.png|thumb|center|400px|The Review Report page showing reviewer scores and grade assignment.]]&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|left|x350px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
[[File:Assignment_Review_Strategy_tab.png|thumb|right|x350px|Review the strategy tab for an assignment where min and max score for the reviews can be set.]]&lt;br /&gt;
[[File:Assignment_Etc_tab.png|thumb|none|x350px|The implemented reports have been listed in the View Reports button.]]&lt;br /&gt;
[[File:Review_Report_1.png|thumb|none|x350px|Review Report page with collapsible legend.]]&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168308</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168308"/>
		<updated>2026-08-26T16:12:14Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* ReviewReportPage */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
{| style=&amp;quot;width:100%; border-spacing:0px;&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
| style=&amp;quot;width:50%; padding:8px; vertical-align:top;&amp;quot; |&lt;br /&gt;
[[File:Manage_Assignments_tab.png |thumb|center|400px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
| style=&amp;quot;width:50%; padding:8px; vertical-align:top;&amp;quot; |&lt;br /&gt;
[[File:Assignment_Review_Strategy_tab.png|thumb|center|400px|Review the strategy tab for an assignment where min and max score for the reviews can be set.]]&lt;br /&gt;
|-&lt;br /&gt;
| style=&amp;quot;width:50%; padding:8px; vertical-align:top;&amp;quot; |&lt;br /&gt;
[[File:Assignment_Etc_tab.png|thumb|center|400px|The implemented reports have been listed in the View Reports button.]]&lt;br /&gt;
| style=&amp;quot;width:50%; padding:8px; vertical-align:top;&amp;quot; |&lt;br /&gt;
[[File:Review_Report_1.png|thumb|center|400px|The Review Report page showing reviewer scores and grade assignment.]]&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|left|x350px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
[[File:Assignment_Review_Strategy_tab.png|thumb|right|x350px|Review the strategy tab for an assignment where min and max score for the reviews can be set.]]&lt;br /&gt;
[[File:Assignment_Etc_tab.png|thumb|none|x350px|The implemented reports have been listed in the View Reports button.]]&lt;br /&gt;
[[File:Review_Report_1.png|thumb|none|x350px|Review Report page with collapsible legend.]]&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168307</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168307"/>
		<updated>2026-08-26T16:12:01Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* ReviewReportPage */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
{| style=&amp;quot;width:100%; border-spacing:0;&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
| style=&amp;quot;width:50%; padding:8px; vertical-align:top;&amp;quot; |&lt;br /&gt;
[[File:Manage_Assignments_tab.png |thumb|center|400px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
| style=&amp;quot;width:50%; padding:8px; vertical-align:top;&amp;quot; |&lt;br /&gt;
[[File:Assignment_Review_Strategy_tab.png|thumb|center|400px|Review the strategy tab for an assignment where min and max score for the reviews can be set.]]&lt;br /&gt;
|-&lt;br /&gt;
| style=&amp;quot;width:50%; padding:8px; vertical-align:top;&amp;quot; |&lt;br /&gt;
[[File:Assignment_Etc_tab.png|thumb|center|400px|The implemented reports have been listed in the View Reports button.]]&lt;br /&gt;
| style=&amp;quot;width:50%; padding:8px; vertical-align:top;&amp;quot; |&lt;br /&gt;
[[File:Review_Report_1.png|thumb|center|400px|The Review Report page showing reviewer scores and grade assignment.]]&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|left|x350px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
[[File:Assignment_Review_Strategy_tab.png|thumb|right|x350px|Review the strategy tab for an assignment where min and max score for the reviews can be set.]]&lt;br /&gt;
[[File:Assignment_Etc_tab.png|thumb|none|x350px|The implemented reports have been listed in the View Reports button.]]&lt;br /&gt;
[[File:Review_Report_1.png|thumb|none|x350px|Review Report page with collapsible legend.]]&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168306</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168306"/>
		<updated>2026-08-26T16:10:02Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* ReviewReportPage */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
{| style=&amp;quot;width:100%;&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
| style=&amp;quot;width:50%; padding:8px; vertical-align:top;&amp;quot; |&lt;br /&gt;
[[File:Manage_Assignments_tab.png |thumb|center|400px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
| style=&amp;quot;width:50%; padding:8px; vertical-align:top;&amp;quot; |&lt;br /&gt;
[[File:Assignment_Review_Strategy_tab.png|thumb|center|400px|Review the strategy tab for an assignment where min and max score for the reviews can be set.]]&lt;br /&gt;
|-&lt;br /&gt;
| style=&amp;quot;width:50%; padding:8px; vertical-align:top;&amp;quot; |&lt;br /&gt;
[[File:Assignment_Etc_tab.png|thumb|center|400px|The implemented reports have been listed in the View Reports button.]]&lt;br /&gt;
| style=&amp;quot;width:50%; padding:8px; vertical-align:top;&amp;quot; |&lt;br /&gt;
[[File:Review_Report_1.png|thumb|center|400px|The Review Report page showing reviewer scores and grade assignment.]]&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|left|x350px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
[[File:Assignment_Review_Strategy_tab.png|thumb|right|x350px|Review the strategy tab for an assignment where min and max score for the reviews can be set.]]&lt;br /&gt;
[[File:Assignment_Etc_tab.png|thumb|none|x350px|The implemented reports have been listed in the View Reports button.]]&lt;br /&gt;
[[File:Review_Report_1.png|thumb|none|x350px|Review Report page with collapsible legend.]]&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168305</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168305"/>
		<updated>2026-08-26T15:58:51Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* ReviewReportPage */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|left|x350px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
[[File:Assignment_Review_Strategy_tab.png|thumb|right|x350px|Review the strategy tab for an assignment where min and max score for the reviews can be set.]]&lt;br /&gt;
[[File:Assignment_Etc_tab.png|thumb|none|x350px|The implemented reports have been listed in the View Reports button.]]&lt;br /&gt;
[[File:Review_Report_1.png|thumb|none|x350px|Review Report page with collapsible legend.]]&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168304</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168304"/>
		<updated>2026-08-26T15:58:33Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* Frontend Architecture */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|left|x350px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
[[File:Assignment_Review_Strategy_tab.png|thumb|right|x350px|Review the strategy tab for an assignment where min and max score for the reviews can be set.]]&lt;br /&gt;
[[File:Assignment_Etc–tab.png|thumb|none|x350px|The implemented reports have been listed in the View Reports button.]]&lt;br /&gt;
[[File:Review_Report_1.png|thumb|none|x350px|Review Report page with collapsible legend.]]&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=File:Review_Report_1.png&amp;diff=168303</id>
		<title>File:Review Report 1.png</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=File:Review_Report_1.png&amp;diff=168303"/>
		<updated>2026-08-26T15:57:22Z</updated>

		<summary type="html">&lt;p&gt;Blalu: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=File:Assignment_Etc_tab.png&amp;diff=168302</id>
		<title>File:Assignment Etc tab.png</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=File:Assignment_Etc_tab.png&amp;diff=168302"/>
		<updated>2026-08-26T15:53:57Z</updated>

		<summary type="html">&lt;p&gt;Blalu: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168301</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168301"/>
		<updated>2026-08-26T15:51:40Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* ReviewReportPage */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|left|x400px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
[[File:Assignment_Review_Strategy_tab.png|thumb|right|x400px|Review strategy tab for an assignment where min and max score for the reviews can be set.]]&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|none|x400px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|none|x400px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168300</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168300"/>
		<updated>2026-08-26T15:51:15Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* ReviewReportPage */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|left|x400px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
[[File:Assignment_Review_Strategy_tab.png|thumb|right|x400px|Review strategy tab for an assignment where min and max score for a reviewer can be set.]]&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|none|x400px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|none|x400px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168299</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168299"/>
		<updated>2026-08-26T15:47:32Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* ReviewReportPage */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|left|x400px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
[[File:Assignment_Review_Strategy_tab.png|thumb|right|x400px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|none|x400px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|none|x400px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168298</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168298"/>
		<updated>2026-08-26T15:47:14Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* ReviewReportPage */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|none|x400px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
[[File:Assignment_Review_Strategy_tab.png|thumb|right|x400px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|none|x400px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|none|x400px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=File:Assignment_Review_Strategy_tab.png&amp;diff=168297</id>
		<title>File:Assignment Review Strategy tab.png</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=File:Assignment_Review_Strategy_tab.png&amp;diff=168297"/>
		<updated>2026-08-26T15:46:32Z</updated>

		<summary type="html">&lt;p&gt;Blalu: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168296</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168296"/>
		<updated>2026-08-26T15:42:53Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* ReviewReportPage */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|none|x400px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168295</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168295"/>
		<updated>2026-08-26T15:42:42Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* ReviewReportPage */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|none|400px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168294</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168294"/>
		<updated>2026-08-26T15:42:34Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* ReviewReportPage */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
[[File:Manage_Assignments_tab.png|frame|none|400px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168293</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168293"/>
		<updated>2026-08-26T15:42:18Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* ReviewReportPage */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
[[File:Manage_Assignments_tab.png|frame|none|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168292</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168292"/>
		<updated>2026-08-26T15:40:56Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* ReviewReportPage */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|none|x300px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168291</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168291"/>
		<updated>2026-08-26T15:40:46Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* ReviewReportPage */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|none|x500px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168290</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168290"/>
		<updated>2026-08-26T15:40:31Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* ReviewReportPage */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|none|x200px|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168289</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168289"/>
		<updated>2026-08-26T15:37:40Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* ReviewReportPage */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
[[File:Manage_Assignments_tab.png|thumb|left|This is the Assignments tab for an instructor/admin.]]&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=File:Manage_Assignments_tab.png&amp;diff=168288</id>
		<title>File:Manage Assignments tab.png</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=File:Manage_Assignments_tab.png&amp;diff=168288"/>
		<updated>2026-08-26T15:35:11Z</updated>

		<summary type="html">&lt;p&gt;Blalu: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168287</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168287"/>
		<updated>2026-08-26T14:54:50Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* Bookmark Rating Report (bookmark_rating_response_map) */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
To be implemented.&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168286</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168286"/>
		<updated>2026-08-26T14:53:55Z</updated>

		<summary type="html">&lt;p&gt;Blalu: /* Dispatch Mechanism */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport, # To be implemented&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport, # To be implemented&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;BookmarkRatingResponseMap&amp;lt;/code&amp;gt; records, accumulating distinct bookmark IDs into a Set. Project topics are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
==== Bug Fixed During Port ====&lt;br /&gt;
&lt;br /&gt;
The model&#039;s &amp;lt;code&amp;gt;bookmark_response_report&amp;lt;/code&amp;gt; in Repo Y was incorrectly calling &amp;lt;code&amp;gt;.pluck(:reviewed_object_id)&amp;lt;/code&amp;gt;, which returns assignment IDs. Bookmark IDs are stored in &amp;lt;code&amp;gt;reviewee_id&amp;lt;/code&amp;gt;. Fixed to &amp;lt;code&amp;gt;.pluck(:reviewee_id)&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;bookmark_rating_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;bookmark_ids&amp;quot;: [10, 14, 22],&lt;br /&gt;
  &amp;quot;topics&amp;quot;: [{ &amp;quot;id&amp;quot;: 3, &amp;quot;topic_name&amp;quot;: &amp;quot;Machine Learning&amp;quot; }]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168285</id>
		<title>Reports and Course Summary</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Reports_and_Course_Summary&amp;diff=168285"/>
		<updated>2026-08-26T14:42:07Z</updated>

		<summary type="html">&lt;p&gt;Blalu: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Report Generation Framework =&lt;br /&gt;
&lt;br /&gt;
== Design and Implementation Documentation ==&lt;br /&gt;
&lt;br /&gt;
=== Expertiza Reimplementation Back-End ===&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
The reporting subsystem was ported from the original Expertiza codebase (referred to as &#039;&#039;&#039;Repo X&#039;&#039;&#039;) into the reimplemented Rails API back-end (referred to as &#039;&#039;&#039;Repo Y&#039;&#039;&#039;) and redesigned in the process.&lt;br /&gt;
&lt;br /&gt;
Repo X used a Rails helper module (&amp;lt;code&amp;gt;ReportFormatterHelper&amp;lt;/code&amp;gt;) that assigned instance variables, such as &amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;, for ERB views.&lt;br /&gt;
&lt;br /&gt;
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a &#039;&#039;&#039;streaming reduce pipeline&#039;&#039;&#039; that returns plain Ruby hashes rendered as JSON.&lt;br /&gt;
&lt;br /&gt;
=== Design Goals ===&lt;br /&gt;
&lt;br /&gt;
* Avoid loading entire result sets into memory at once.&lt;br /&gt;
* Keep domain-specific computation out of the base class.&lt;br /&gt;
* Make each report type independently composable and testable.&lt;br /&gt;
* Fix N+1 query patterns present in Repo X.&lt;br /&gt;
&lt;br /&gt;
== Anti-Patterns Addressed ==&lt;br /&gt;
&lt;br /&gt;
The design directly addresses two anti-patterns identified during architectural review of Repo X.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;fetch_responses&amp;lt;/code&amp;gt; Anti-Pattern ===&lt;br /&gt;
&lt;br /&gt;
Repo X loaded all response records into an unnamed, ad-hoc array before processing:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG&lt;br /&gt;
responses = fetch_responses   # full memory load&lt;br /&gt;
grouped   = group(responses)&lt;br /&gt;
metrics   = compute_metrics(grouped)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.&lt;br /&gt;
&lt;br /&gt;
The fix is to never materialise all rows at once. Instead, use &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
=== Default Metrics in the Base Class ===&lt;br /&gt;
&lt;br /&gt;
Repo X placed domain-specific math directly in the base class:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# WRONG — in BaseReport&lt;br /&gt;
def compute_metrics(grouped)&lt;br /&gt;
  grouped.transform_values do |responses|&lt;br /&gt;
    {&lt;br /&gt;
      count:     responses.size,&lt;br /&gt;
      avg_score: responses.map(&amp;amp;:score).sum / responses.size.to_f&lt;br /&gt;
    }&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This ties every subclass to one particular shape of computation. The fix is to move all domain math into per-report accumulator logic. The base class contains &#039;&#039;&#039;zero domain math&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==== What &amp;quot;Domain Math&amp;quot; Means ====&lt;br /&gt;
&lt;br /&gt;
Domain math refers to the business-logic calculations specific to a particular report type.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report !! Its domain math&lt;br /&gt;
|-&lt;br /&gt;
| Review scores || &amp;lt;code&amp;gt;(raw_score / max_score) * 100&amp;lt;/code&amp;gt; — percentage score per reviewer per round&lt;br /&gt;
|-&lt;br /&gt;
| Avg/ranges || &amp;lt;code&amp;gt;max, min, sum / size&amp;lt;/code&amp;gt; — score aggregates across a team&#039;s reviewers&lt;br /&gt;
|-&lt;br /&gt;
| Feedback || Bucket response IDs into &amp;lt;code&amp;gt;round_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_2&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;round_3&amp;lt;/code&amp;gt; arrays&lt;br /&gt;
|-&lt;br /&gt;
| Bookmark rating || Collect distinct bookmark IDs into a Set&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rule:&#039;&#039;&#039; &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt; only defines &#039;&#039;how&#039;&#039; to run the pipeline — stream, group, fold, finalize. The &#039;&#039;what&#039;&#039; to compute belongs entirely inside each report&#039;s own &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt; methods.&lt;br /&gt;
&lt;br /&gt;
== Architecture: The Streaming Pipeline ==&lt;br /&gt;
&lt;br /&gt;
=== Pipeline Shape ===&lt;br /&gt;
&lt;br /&gt;
All reports are built on a single pipeline template defined in &amp;lt;code&amp;gt;Reports::BaseReport&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def run&lt;br /&gt;
  state = initial_state&lt;br /&gt;
  source.find_each(batch_size: 500) do |row|&lt;br /&gt;
    accumulate(state, grouper.call(row), row)&lt;br /&gt;
  end&lt;br /&gt;
  finalize(state)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The pipeline consists of four concerns:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Responsibility&lt;br /&gt;
|-&lt;br /&gt;
| Source || ActiveRecord relation streamed via &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt;. Subclasses use &amp;lt;code&amp;gt;includes(...)&amp;lt;/code&amp;gt; to eagerly load associations and prevent N+1 queries.&lt;br /&gt;
|-&lt;br /&gt;
| Grouper || A lambda &amp;lt;code&amp;gt;(row) -&amp;gt; key&amp;lt;/code&amp;gt; that determines how rows are bucketed in the accumulator state.&lt;br /&gt;
|-&lt;br /&gt;
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.&lt;br /&gt;
|-&lt;br /&gt;
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Memory Model ===&lt;br /&gt;
&lt;br /&gt;
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.&lt;br /&gt;
&lt;br /&gt;
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.&lt;br /&gt;
&lt;br /&gt;
=== Base Class Definition ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
module Reports&lt;br /&gt;
  class BaseReport&lt;br /&gt;
    def initialize(assignment)&lt;br /&gt;
      @assignment = assignment&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def run&lt;br /&gt;
      state = initial_state&lt;br /&gt;
      source.find_each(batch_size: 500) do |row|&lt;br /&gt;
        accumulate(state, grouper.call(row), row)&lt;br /&gt;
      end&lt;br /&gt;
      finalize(state)&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    private&lt;br /&gt;
&lt;br /&gt;
    def source        = raise NotImplementedError&lt;br /&gt;
    def grouper       = raise NotImplementedError&lt;br /&gt;
    def initial_state = raise NotImplementedError&lt;br /&gt;
&lt;br /&gt;
    def accumulate(_state, _key, _row)&lt;br /&gt;
      raise NotImplementedError&lt;br /&gt;
    end&lt;br /&gt;
&lt;br /&gt;
    def finalize(state) = state&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Controller ==&lt;br /&gt;
&lt;br /&gt;
=== Dispatch Mechanism ===&lt;br /&gt;
&lt;br /&gt;
The controller uses a constant hash to map type strings to report classes, replacing the &amp;lt;code&amp;gt;send(type)&amp;lt;/code&amp;gt; meta-programming pattern used in Repo X.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
REPORT_CLASSES = {&lt;br /&gt;
  &#039;review_response_map&#039;          =&amp;gt; Reports::ReviewReport,&lt;br /&gt;
  &#039;feedback_response_map&#039;        =&amp;gt; Reports::FeedbackReport,&lt;br /&gt;
  &#039;teammate_review_response_map&#039; =&amp;gt; Reports::TeammateReviewReport,&lt;br /&gt;
  &#039;bookmark_rating_response_map&#039; =&amp;gt; Reports::BookmarkRatingReport,&lt;br /&gt;
  &#039;basic&#039;                        =&amp;gt; Reports::BasicReport&lt;br /&gt;
}.freeze&lt;br /&gt;
&lt;br /&gt;
def response_report&lt;br /&gt;
  type         = params.dig(:report, :type) || params[:type] || &#039;basic&#039;&lt;br /&gt;
  report_class = REPORT_CLASSES[type]&lt;br /&gt;
&lt;br /&gt;
  unless report_class&lt;br /&gt;
    return render json: { error: &amp;quot;Unknown report type: #{type}&amp;quot; },&lt;br /&gt;
                  status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  assignment = Assignment.find(params[:assignment_id] || params[:id])&lt;br /&gt;
  data = report_class.new(assignment).run&lt;br /&gt;
  render json: { type: type, assignment_id: assignment.id }.merge(data)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET  /reports/response_report?assignment_id=&amp;lt;id&amp;gt;&amp;amp;type=&amp;lt;type&amp;gt;&lt;br /&gt;
POST /reports/response_report&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Report Implementations ==&lt;br /&gt;
&lt;br /&gt;
=== Review Report (&amp;lt;code&amp;gt;review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
The review report is the most complex. It is implemented as a coordinator class (&amp;lt;code&amp;gt;ReviewReport&amp;lt;/code&amp;gt;) that runs three independent inner pipelines and merges their results.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Pipeline !! Source !! Groups by !! Produces&lt;br /&gt;
|-&lt;br /&gt;
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list&lt;br /&gt;
|-&lt;br /&gt;
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee&lt;br /&gt;
|-&lt;br /&gt;
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==== N+1 Fix: Precomputed Max Question Score ====&lt;br /&gt;
&lt;br /&gt;
Repo X called &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a &amp;lt;code&amp;gt;round -&amp;gt; max_question_score&amp;lt;/code&amp;gt; map with a single query before the pipeline runs:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_max_q_scores&lt;br /&gt;
  AssignmentQuestionnaire&lt;br /&gt;
    .joins(:questionnaire)&lt;br /&gt;
    .where(assignment_id: @assignment.id)&lt;br /&gt;
    .pluck(:used_in_round, &#039;questionnaires.max_question_score&#039;)&lt;br /&gt;
    .to_h&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
# Used inside accumulate:&lt;br /&gt;
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2, &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot;, &amp;quot;handle&amp;quot;: &amp;quot;alice&amp;quot; }&lt;br /&gt;
  ],&lt;br /&gt;
  &amp;quot;review_scores&amp;quot;: { &amp;quot;5&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;3&amp;quot;: 87.5 } } },&lt;br /&gt;
  &amp;quot;avg_and_ranges&amp;quot;: { &amp;quot;3&amp;quot;: { &amp;quot;1&amp;quot;: { &amp;quot;max&amp;quot;: 92.0, &amp;quot;min&amp;quot;: 75.0, &amp;quot;avg&amp;quot;: 83.5 } } }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Feedback Report (&amp;lt;code&amp;gt;feedback_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.&lt;br /&gt;
&lt;br /&gt;
Deduplication uses a Set (O(1) lookup), rather than the array-based &amp;lt;code&amp;gt;seen_map_round_keys.include?&amp;lt;/code&amp;gt; from Repo X (O(n) lookup). Authors are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;, not inside the stream.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response (varying rubrics) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;feedback_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;authors&amp;quot;: [{ &amp;quot;id&amp;quot;: 7, &amp;quot;name&amp;quot;: &amp;quot;bob&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Bob Jones&amp;quot; }],&lt;br /&gt;
  &amp;quot;review_response_ids&amp;quot;: {&lt;br /&gt;
    &amp;quot;round_1&amp;quot;: [12, 15], &amp;quot;round_2&amp;quot;: [18], &amp;quot;round_3&amp;quot;: []&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Teammate Review Report (&amp;lt;code&amp;gt;teammate_review_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records grouped by &amp;lt;code&amp;gt;reviewer_id&amp;lt;/code&amp;gt;. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.&lt;br /&gt;
&lt;br /&gt;
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;teammate_review_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;reviewers&amp;quot;: [&lt;br /&gt;
    { &amp;quot;reviewer_id&amp;quot;: 5, &amp;quot;user_id&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;name&amp;quot;: &amp;quot;alice&amp;quot;, &amp;quot;full_name&amp;quot;: &amp;quot;Alice Smith&amp;quot; }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Bookmark Rating Report (&amp;lt;code&amp;gt;bookmark_rating_response_map&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Streams &amp;lt;code&amp;gt;BookmarkRatingResponseMap&amp;lt;/code&amp;gt; records, accumulating distinct bookmark IDs into a Set. Project topics are fetched once in &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
==== Bug Fixed During Port ====&lt;br /&gt;
&lt;br /&gt;
The model&#039;s &amp;lt;code&amp;gt;bookmark_response_report&amp;lt;/code&amp;gt; in Repo Y was incorrectly calling &amp;lt;code&amp;gt;.pluck(:reviewed_object_id)&amp;lt;/code&amp;gt;, which returns assignment IDs. Bookmark IDs are stored in &amp;lt;code&amp;gt;reviewee_id&amp;lt;/code&amp;gt;. Fixed to &amp;lt;code&amp;gt;.pluck(:reviewee_id)&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;bookmark_rating_response_map&amp;quot;,&lt;br /&gt;
  &amp;quot;bookmark_ids&amp;quot;: [10, 14, 22],&lt;br /&gt;
  &amp;quot;topics&amp;quot;: [{ &amp;quot;id&amp;quot;: 3, &amp;quot;topic_name&amp;quot;: &amp;quot;Machine Learning&amp;quot; }]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Basic Report (&amp;lt;code&amp;gt;basic&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; parameter is provided.&lt;br /&gt;
&lt;br /&gt;
==== Sample Response ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,&lt;br /&gt;
  &amp;quot;assignment_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;assignment&amp;quot;: {&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
    &amp;quot;num_review_rounds&amp;quot;: 2,&lt;br /&gt;
    &amp;quot;varying_rubrics_by_round&amp;quot;: true&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course Grade Summary ===&lt;br /&gt;
&lt;br /&gt;
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt;, which includes &amp;lt;code&amp;gt;PenaltyHelper&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/grade_summary&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Peer score&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_peer_scores&amp;lt;/code&amp;gt; bulk-loads all &amp;lt;code&amp;gt;ReviewResponseMap&amp;lt;/code&amp;gt; records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from &amp;lt;code&amp;gt;ReviewGrade#grade_for_reviewer&amp;lt;/code&amp;gt;; defaults to 1.0 if absent.&lt;br /&gt;
* &#039;&#039;&#039;Instructor grade&#039;&#039;&#039; — &amp;lt;code&amp;gt;team.grade_for_submission&amp;lt;/code&amp;gt; minus the late penalty from &amp;lt;code&amp;gt;PenaltyHelper#get_penalty(participant_id)[:submission]&amp;lt;/code&amp;gt;. Returns &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt; when no grade has been set.&lt;br /&gt;
* &#039;&#039;&#039;Penalty guard&#039;&#039;&#039; — &amp;lt;code&amp;gt;get_penalty&amp;lt;/code&amp;gt; returns &amp;lt;code&amp;gt;{ submission: 0, review: 0, meta_review: 0 }&amp;lt;/code&amp;gt; immediately when the assignment has no late policy (&amp;lt;code&amp;gt;@penalty_per_unit&amp;lt;/code&amp;gt; is nil), avoiding constant-lookup errors for &amp;lt;code&amp;gt;MetareviewResponseMap&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Calibrated assignments excluded&#039;&#039;&#039; — assignments where &amp;lt;code&amp;gt;is_calibrated: true&amp;lt;/code&amp;gt; are filtered out.&lt;br /&gt;
* &#039;&#039;&#039;Empty assignments excluded&#039;&#039;&#039; — assignments with no participants are rejected from the response.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
def precompute_peer_scores(assignment_ids, team_ids)&lt;br /&gt;
  return {} if team_ids.empty?&lt;br /&gt;
&lt;br /&gt;
  maps = ReviewResponseMap&lt;br /&gt;
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)&lt;br /&gt;
    .includes(responses: :scores)&lt;br /&gt;
&lt;br /&gt;
  reviewer_grades = ReviewGrade&lt;br /&gt;
    .where(participant_id: maps.map(&amp;amp;:reviewer_id).uniq)&lt;br /&gt;
    .index_by(&amp;amp;:participant_id)&lt;br /&gt;
&lt;br /&gt;
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }&lt;br /&gt;
  maps.each do |map|&lt;br /&gt;
    w = reviewer_grades[map.reviewer_id]&amp;amp;.grade_for_reviewer || 1.0&lt;br /&gt;
    map.responses.select(&amp;amp;:is_submitted).each do |resp|&lt;br /&gt;
      max = resp.maximum_score&lt;br /&gt;
      next if max.zero?&lt;br /&gt;
      pct = resp.aggregate_questionnaire_score.to_f / max * 100&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w&lt;br /&gt;
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;has_topics&amp;quot;: true }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        {&lt;br /&gt;
          &amp;quot;assignment_id&amp;quot;: 10,&lt;br /&gt;
          &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;,&lt;br /&gt;
          &amp;quot;topic&amp;quot;: &amp;quot;Machine Learning&amp;quot;,&lt;br /&gt;
          &amp;quot;peer_score&amp;quot;: 87.5,&lt;br /&gt;
          &amp;quot;instructor_grade&amp;quot;: 85.0&lt;br /&gt;
        }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;final_grade&amp;quot;: 85.0&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Course All Reviews ===&lt;br /&gt;
&lt;br /&gt;
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Route:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
GET /courses/:id/course_report/all_reviews&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key computations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Teammate scores&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_scores&amp;lt;/code&amp;gt; bulk-loads &amp;lt;code&amp;gt;TeammateReviewResponseMap&amp;lt;/code&amp;gt; records and returns &amp;lt;code&amp;gt;{ participant_id =&amp;gt; &amp;quot;avg%&amp;quot; }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Teammate count&#039;&#039;&#039; — &amp;lt;code&amp;gt;precompute_teammate_counts&amp;lt;/code&amp;gt; counts distinct teammates per user across all assignments in the course.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Sample Response:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;course_id&amp;quot;: 1,&lt;br /&gt;
  &amp;quot;course_name&amp;quot;: &amp;quot;CSC 517&amp;quot;,&lt;br /&gt;
  &amp;quot;assignments&amp;quot;: [{ &amp;quot;id&amp;quot;: 10, &amp;quot;name&amp;quot;: &amp;quot;Project 1&amp;quot; }],&lt;br /&gt;
  &amp;quot;rows&amp;quot;: [&lt;br /&gt;
    {&lt;br /&gt;
      &amp;quot;user_id&amp;quot;: 5,&lt;br /&gt;
      &amp;quot;user_name&amp;quot;: &amp;quot;alice&amp;quot;,&lt;br /&gt;
      &amp;quot;teammate_count&amp;quot;: 2,&lt;br /&gt;
      &amp;quot;assignments&amp;quot;: [&lt;br /&gt;
        { &amp;quot;assignment_id&amp;quot;: 10, &amp;quot;assignment_name&amp;quot;: &amp;quot;Project 1&amp;quot;, &amp;quot;teammate_review&amp;quot;: &amp;quot;83%&amp;quot; }&lt;br /&gt;
      ],&lt;br /&gt;
      &amp;quot;aggregate&amp;quot;: &amp;quot;83%&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
  ]&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== ReviewGrade Model ==&lt;br /&gt;
&lt;br /&gt;
Stores an instructor&#039;s grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Migrations:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260810135129_create_review_grades.rb&lt;br /&gt;
create_table :review_grades, if_not_exists: true do |t|&lt;br /&gt;
  t.integer  :participant_id, null: false, index: { unique: true }&lt;br /&gt;
  t.float    :grade_for_reviewer&lt;br /&gt;
  t.text     :comment_for_reviewer&lt;br /&gt;
  t.integer  :grader_id&lt;br /&gt;
  t.timestamps&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# 20260813000001_add_instructor_grade_scores_to_assignments.rb&lt;br /&gt;
add_column :assignments, :instructor_grade_min_score, :integer&lt;br /&gt;
add_column :assignments, :instructor_grade_max_score, :integer&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
These two columns back the &amp;lt;code&amp;gt;instructor_grade_min_score&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructor_grade_max_score&amp;lt;/code&amp;gt; fields exposed in &amp;lt;code&amp;gt;assignment_params&amp;lt;/code&amp;gt;. The frontend&#039;s GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Endpoint:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
PATCH /review_reports/:id/update_grade&lt;br /&gt;
# params: { grade_for_reviewer:, comment_for_reviewer: }&lt;br /&gt;
# :id is the participant_id (AssignmentParticipant)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Known limitation:&#039;&#039;&#039; &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; is per-participant (one row per reviewer), not per response-map. This means the grade/comment field appears on every row for a reviewer in the UI. This matches the original Expertiza schema and is deferred.&lt;br /&gt;
&lt;br /&gt;
== Frontend Architecture ==&lt;br /&gt;
&lt;br /&gt;
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.&lt;br /&gt;
&lt;br /&gt;
=== ReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/ReviewReportPage/ReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Rounds 1–5 visibility toggled per reviewer row&lt;br /&gt;
* &#039;&#039;&#039;MetricsChart&#039;&#039;&#039; — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely&lt;br /&gt;
* Status color coding per reviewer, including brown pre-population on reload from &amp;lt;code&amp;gt;reviewer_grades&amp;lt;/code&amp;gt; in the fetch response&lt;br /&gt;
* Scores awarded and average score display&lt;br /&gt;
* &#039;&#039;&#039;Grade/comment save&#039;&#039;&#039; via &amp;lt;code&amp;gt;PATCH /review_reports/:id/update_grade&amp;lt;/code&amp;gt;&lt;br /&gt;
* CSV export, column sorting, search filter&lt;br /&gt;
* Summary modal per reviewer&lt;br /&gt;
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via &amp;lt;code&amp;gt;getColorClass&amp;lt;/code&amp;gt;&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;k = min(maxScore, 10)&amp;lt;/code&amp;gt; HSL bands, green → red spectrum&lt;br /&gt;
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max&lt;br /&gt;
* Review metrics column narrowed; &amp;lt;code&amp;gt;barGap=1&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;slotWidth=75px&amp;lt;/code&amp;gt; between groups&lt;br /&gt;
&lt;br /&gt;
=== TeammateReviewReportPage ===&lt;br /&gt;
&lt;br /&gt;
File: &amp;lt;code&amp;gt;src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Team grouping column&lt;br /&gt;
* View modal showing scores per reviewee&lt;br /&gt;
* Assignment name heading&lt;br /&gt;
* Compact layout: &amp;lt;code&amp;gt;p-3&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;line-height: 1.4&amp;lt;/code&amp;gt;, padding &amp;lt;code&amp;gt;6px 24px 6px 8px&amp;lt;/code&amp;gt; on th/td&lt;br /&gt;
* &amp;lt;code&amp;gt;tableStyle={{ width: &amp;quot;fit-content&amp;quot; }}&amp;lt;/code&amp;gt; — table shrinks to content, no full-width stretch&lt;br /&gt;
* &amp;quot;Reviews&amp;quot; header on the view-link column&lt;br /&gt;
* CSS scoped under &amp;lt;code&amp;gt;.teammate-review-report-page&amp;lt;/code&amp;gt; (does not affect Review Report)&lt;br /&gt;
* Status color &amp;lt;code&amp;gt;#dc3545&amp;lt;/code&amp;gt; for below-threshold scores&lt;br /&gt;
&lt;br /&gt;
=== Course Report Pages ===&lt;br /&gt;
&lt;br /&gt;
Accessible from the course list: expand a course → assignment row → &amp;quot;View Review Report&amp;quot; button.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Page !! Frontend Route !! Backend Endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseGradeSummaryPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/grade-summary&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/grade_summary&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseAllReviewsPage&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;courses/:courseId/course-report/all-reviews&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;GET /courses/:id/course_report/all_reviews&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Both pages render a heat-grid layout where each cell&#039;s color is determined by the value&#039;s position in the observed data range (not a fixed rubric max).&lt;br /&gt;
&lt;br /&gt;
=== Shared Utilities &amp;amp; Heat Grid ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/heatgridUtils.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;getHeatColorClass(value, dataMin, dataMax)&amp;lt;/code&amp;gt; — relative coloring, maps value to a CSS class based on its position between &amp;lt;code&amp;gt;dataMin&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;dataMax&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;getColorClass(score)&amp;lt;/code&amp;gt; — absolute grade-scale coloring (90/80/70/60 cutoffs).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;src/utils/reviewTypes.ts&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Exports &amp;lt;code&amp;gt;ReviewData&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SectionHeaderData&amp;lt;/code&amp;gt; interfaces. Re-exported from &amp;lt;code&amp;gt;ViewTeamGrades/App.tsx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Heat grid CSS&#039;&#039;&#039; (in &amp;lt;code&amp;gt;custom.scss&amp;lt;/code&amp;gt;, global, after Bootstrap import, with &amp;lt;code&amp;gt;!important&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */&lt;br /&gt;
.c4 { background: #5cb85c; }               /* B */&lt;br /&gt;
.c3 { background: #f0ad4e; }               /* C */&lt;br /&gt;
.c2 { background: #d9534f; color: #fff; }  /* D */&lt;br /&gt;
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */&lt;br /&gt;
.c0 { background: #e0e0e0; }               /* no data */&lt;br /&gt;
.cf { background: #f8f8f8; }               /* empty */&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Frontend Routes (App.tsx) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage&lt;br /&gt;
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Dropdown on the Review Report page: &#039;&#039;Review Report&#039;&#039; | &#039;&#039;Teammate Review Report&#039;&#039;. (Per Team report removed — no counterpart in Repo X.)&lt;br /&gt;
&lt;br /&gt;
== Authorization Changes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Controller !! Change&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;action_allowed? → current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;. Previously defaulted to &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt; — any user could CRUD assignments.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CoursesController#index&amp;lt;/code&amp;gt; || Scoped to &amp;lt;code&amp;gt;Course.where(instructor_id: current_user.id)&amp;lt;/code&amp;gt;. Admins still see &amp;lt;code&amp;gt;Course.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;AssignmentsController#index&amp;lt;/code&amp;gt; || Scoped to instructor&#039;s own assignments plus assignments under their courses. Admins see &amp;lt;code&amp;gt;Assignment.all&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ReportsController&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;action_allowed?&amp;lt;/code&amp;gt; now checks &amp;lt;code&amp;gt;current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?&amp;lt;/code&amp;gt;. Removed overly broad &amp;lt;code&amp;gt;instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; || New controller; auth: &amp;lt;code&amp;gt;current_user_has_instructor_privileges?&amp;lt;/code&amp;gt;.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== RSpec Test Coverage ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Spec file !! Covers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_reports_controller_spec.rb&amp;lt;/code&amp;gt; || Response report fetch, update_grade endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/review_grade_conflicts_spec.rb&amp;lt;/code&amp;gt; || ReviewGrade conflict detection endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/course_reports_controller_spec.rb&amp;lt;/code&amp;gt; || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/teammate_review_report_spec.rb&amp;lt;/code&amp;gt; || Teammate review report endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/models/response_map_spec.rb&amp;lt;/code&amp;gt; || Added &amp;lt;code&amp;gt;.compute_average_reviewer_score&amp;lt;/code&amp;gt; describe block (8 cases, uses instance_double)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;spec/requests/api/v1/assignment_controller_spec.rb&amp;lt;/code&amp;gt; || show/update with AQ data, instructor_grade_min/max fields&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pending Roadmap ==&lt;br /&gt;
&lt;br /&gt;
Items identified in the 2026-08-19 team meeting, not yet implemented:&lt;br /&gt;
&lt;br /&gt;
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)&lt;br /&gt;
* Toggle to show/hide the metrics/chart column&lt;br /&gt;
* &amp;quot;Volume&amp;quot; column header label derived from active metrics (not hardwired)&lt;br /&gt;
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell&lt;br /&gt;
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round&lt;br /&gt;
* Grade change warning banner when instructor lowers max points below an already-assigned score&lt;br /&gt;
&lt;br /&gt;
Previously completed from the same meeting:&lt;br /&gt;
&lt;br /&gt;
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)&lt;br /&gt;
* Dynamic color bands via &amp;lt;code&amp;gt;scoreToColor()&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;quot;Question&amp;quot; column header renamed to &amp;quot;Item&amp;quot; in heatgrid and feedback table&lt;br /&gt;
* &amp;lt;code&amp;gt;/100&amp;lt;/code&amp;gt; hardcoded scale label removed from GradeCommentCell&lt;br /&gt;
* Avg bar suppressed for rounds where reviewer did not participate&lt;br /&gt;
&lt;br /&gt;
== File Structure ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
app/&lt;br /&gt;
  controllers/&lt;br /&gt;
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch&lt;br /&gt;
    course_reports_controller.rb        Grade summary &amp;amp; all-reviews endpoints  [NEW]&lt;br /&gt;
    assignments_controller.rb           show/update with render body: fix&lt;br /&gt;
  helpers/&lt;br /&gt;
    report_formatter_helper.rb          Empty namespace (logic moved to services)&lt;br /&gt;
    penalty_helper.rb                   get_penalty with nil-guard early return&lt;br /&gt;
  services/&lt;br /&gt;
    reports/&lt;br /&gt;
      base_report.rb                    Abstract pipeline template&lt;br /&gt;
      review_report.rb                  3-pipeline coordinator&lt;br /&gt;
      feedback_report.rb                Single pipeline, round bucketing&lt;br /&gt;
      teammate_review_report.rb         Single pipeline&lt;br /&gt;
      bookmark_rating_report.rb         Single pipeline&lt;br /&gt;
      basic_report.rb                   Simple struct&lt;br /&gt;
  models/&lt;br /&gt;
    review_grade.rb                     Grade &amp;amp; comment per reviewer  [NEW]&lt;br /&gt;
    response.rb                         maximum_score nil guard&lt;br /&gt;
    review_response_map.rb&lt;br /&gt;
    feedback_response_map.rb&lt;br /&gt;
    teammate_review_response_map.rb&lt;br /&gt;
    bookmark_rating_response_map.rb&lt;br /&gt;
&lt;br /&gt;
db/&lt;br /&gt;
  migrate/&lt;br /&gt;
    20260810135129_create_review_grades.rb                      [NEW]&lt;br /&gt;
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]&lt;br /&gt;
&lt;br /&gt;
spec/&lt;br /&gt;
  requests/api/v1/&lt;br /&gt;
    review_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    review_grade_conflicts_spec.rb      [NEW]&lt;br /&gt;
    course_reports_controller_spec.rb   [NEW]&lt;br /&gt;
    teammate_review_report_spec.rb      [NEW]&lt;br /&gt;
    assignment_controller_spec.rb       Updated&lt;br /&gt;
  models/&lt;br /&gt;
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]&lt;br /&gt;
&lt;br /&gt;
# Frontend (reimplementation-front-end repo)&lt;br /&gt;
src/&lt;br /&gt;
  pages/&lt;br /&gt;
    ReviewReportPage/ReviewReportPage.tsx         [NEW]&lt;br /&gt;
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]&lt;br /&gt;
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]&lt;br /&gt;
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]&lt;br /&gt;
  utils/&lt;br /&gt;
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]&lt;br /&gt;
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]&lt;br /&gt;
  styles/&lt;br /&gt;
    custom.scss                         .c0–.c5, .cf heat-grid classes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Blocked Report Types ==&lt;br /&gt;
&lt;br /&gt;
The following report types exist in Repo X but cannot yet be implemented in Repo Y due to missing database tables or models. Each is ready to be added once its dependency is ported.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Report Type !! Missing Dependency !! Repo X Location&lt;br /&gt;
|-&lt;br /&gt;
| calibration || &amp;lt;code&amp;gt;calibrate_to&amp;lt;/code&amp;gt; column on response_maps || report_formatter_helper.rb&lt;br /&gt;
|-&lt;br /&gt;
| self_review || &amp;lt;code&amp;gt;SelfReviewResponseMap&amp;lt;/code&amp;gt; model || self_review_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| survey || &amp;lt;code&amp;gt;survey_deployments&amp;lt;/code&amp;gt; table || survey_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| quiz || &amp;lt;code&amp;gt;quiz_responses&amp;lt;/code&amp;gt; table || quiz_response_map.rb&lt;br /&gt;
|-&lt;br /&gt;
| answer_tagging || &amp;lt;code&amp;gt;tag_prompt_deployments&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;answer_tags&amp;lt;/code&amp;gt; tables || tag_prompt_deployment.rb&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To add a blocked report once its dependencies are available:&lt;br /&gt;
&lt;br /&gt;
# Create &amp;lt;code&amp;gt;app/services/reports/&amp;lt;name&amp;gt;_report.rb&amp;lt;/code&amp;gt; inheriting &amp;lt;code&amp;gt;BaseReport&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Define &amp;lt;code&amp;gt;source&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;grouper&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;initial_state&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Add an entry to &amp;lt;code&amp;gt;ReportsController::REPORT_CLASSES&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Comparison with Repo X ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Concern !! Repo X !! Repo Y&lt;br /&gt;
|-&lt;br /&gt;
| Output format || ERB instance variables (&amp;lt;code&amp;gt;@reviewers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;@review_scores&amp;lt;/code&amp;gt;) || JSON hash from &amp;lt;code&amp;gt;report.run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Loading strategy || All records loaded into arrays at once || &amp;lt;code&amp;gt;find_each&amp;lt;/code&amp;gt; batched streaming&lt;br /&gt;
|-&lt;br /&gt;
| Metrics location || &amp;lt;code&amp;gt;compute_metrics&amp;lt;/code&amp;gt; in helper base || Each report owns &amp;lt;code&amp;gt;accumulate&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;finalize&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Dispatch || &amp;lt;code&amp;gt;send(@type.underscore, params, session)&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;REPORT_CLASSES[type].new(assignment).run&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| N+1 on scores || &amp;lt;code&amp;gt;response.maximum_score&amp;lt;/code&amp;gt; per row — questionnaire lookup each time || Precomputed &amp;lt;code&amp;gt;round→max_score&amp;lt;/code&amp;gt; map, one query before pipeline&lt;br /&gt;
|-&lt;br /&gt;
| Deduplication || &amp;lt;code&amp;gt;Array#include?&amp;lt;/code&amp;gt; — O(n) per check || &amp;lt;code&amp;gt;Set#include?&amp;lt;/code&amp;gt; — O(1) per check&lt;br /&gt;
|-&lt;br /&gt;
| Course reports || Grade book &amp;amp; review summary in ERB views || &amp;lt;code&amp;gt;CourseReportsController&amp;lt;/code&amp;gt; with JSON API endpoints&lt;br /&gt;
|-&lt;br /&gt;
| Reviewer grading || ReviewGrade on Assignment model || Dedicated &amp;lt;code&amp;gt;ReviewGrade&amp;lt;/code&amp;gt; model, &amp;lt;code&amp;gt;update_grade&amp;lt;/code&amp;gt; endpoint&lt;br /&gt;
|-&lt;br /&gt;
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Author ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Name !! Role&lt;br /&gt;
|-&lt;br /&gt;
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework&lt;br /&gt;
|-&lt;br /&gt;
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=File:Student_view_assignment_feedback_page.png&amp;diff=168281</id>
		<title>File:Student view assignment feedback page.png</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=File:Student_view_assignment_feedback_page.png&amp;diff=168281"/>
		<updated>2026-07-25T17:07:03Z</updated>

		<summary type="html">&lt;p&gt;Blalu: Blalu uploaded a new version of File:Student view assignment feedback page.png&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=File:Student_view_assignment_grades_page.png&amp;diff=168280</id>
		<title>File:Student view assignment grades page.png</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=File:Student_view_assignment_grades_page.png&amp;diff=168280"/>
		<updated>2026-07-25T17:06:02Z</updated>

		<summary type="html">&lt;p&gt;Blalu: Blalu uploaded a new version of File:Student view assignment grades page.png&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=File:Student_view_assignment_detail_page.png&amp;diff=168279</id>
		<title>File:Student view assignment detail page.png</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=File:Student_view_assignment_detail_page.png&amp;diff=168279"/>
		<updated>2026-07-25T17:05:01Z</updated>

		<summary type="html">&lt;p&gt;Blalu: Blalu uploaded a new version of File:Student view assignment detail page.png&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;/div&gt;</summary>
		<author><name>Blalu</name></author>
	</entry>
</feed>