Reports and Course Summary: Difference between revisions

From Expertiza_Wiki
Jump to navigation Jump to search
(Created page with "= Report Generation = == Design and Implementation Documentation == === Expertiza Reimplementation Back-End === ---- == Overview == The reporting subsystem was ported from the original Expertiza codebase (referred to as '''Repo X''') into the reimplemented Rails API back-end (referred to as '''Repo Y''') and redesigned in the process. Repo X used a Rails helper module (<code>ReportFormatterHelper</code>) that assigned instance variables, such as <code>@reviewers</cod...")
 
m (Blalu moved page Report Generation Framework (WIP) to Reports and Course Summary: Misspelled title)
 
(34 intermediate revisions by the same user not shown)
Line 1: Line 1:
= Report Generation =
= Reports and Course Summary =
 
== Design and Implementation Documentation ==
== Design and Implementation Documentation ==
=== Expertiza Reimplementation Back-End ===
=== Expertiza Reimplementation Back-End ===
----


== Overview ==
== Overview ==


The reporting subsystem was ported from the original Expertiza codebase
The reporting subsystem was ported from the original Expertiza codebase (referred to as '''Repo X''') into the reimplemented Rails API back-end (referred to as '''Repo Y''') and redesigned in the process.
(referred to as '''Repo X''') into the reimplemented Rails API back-end
(referred to as '''Repo Y''') and redesigned in the process.


Repo X used a Rails helper module (<code>ReportFormatterHelper</code>) that
Repo X used a Rails helper module (<code>ReportFormatterHelper</code>) that assigned instance variables, such as <code>@reviewers</code> and <code>@review_scores</code>, for ERB views.
assigned instance variables, such as <code>@reviewers</code> and
<code>@review_scores</code>, for ERB views.


Since Repo Y is a JSON API with no views, instance variables are not applicable.
Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a '''streaming reduce pipeline''' that returns plain Ruby hashes rendered as JSON.
The architecture was therefore redesigned around a '''streaming reduce pipeline'''
that returns plain Ruby hashes rendered as JSON.


=== Design Goals ===
=== Design Goals ===
Line 25: Line 19:
* Make each report type independently composable and testable.
* Make each report type independently composable and testable.
* Fix N+1 query patterns present in Repo X.
* Fix N+1 query patterns present in Repo X.
----


== Anti-Patterns Addressed ==
== Anti-Patterns Addressed ==


The design directly addresses two anti-patterns identified during architectural
The design directly addresses two anti-patterns identified during architectural review of Repo X.
review of Repo X.


=== The <code>fetch_responses</code> Anti-Pattern ===
=== The <code>fetch_responses</code> Anti-Pattern ===


Repo X loaded all response records into an unnamed, ad-hoc array before
Repo X loaded all response records into an unnamed, ad-hoc array before processing:
processing:


<syntaxhighlight lang="ruby">
<pre>
# WRONG
# WRONG
responses = fetch_responses  # full memory load
responses = fetch_responses  # full memory load
grouped  = group(responses)
grouped  = group(responses)
metrics  = compute_metrics(grouped)
metrics  = compute_metrics(grouped)
</syntaxhighlight>
</pre>


This forces the entire result set into Ruby memory, prevents streaming, and
This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.
makes the intermediate structure implicit.


The fix is to '''never materialise all rows at once'''. Instead, use
The fix is to never materialise all rows at once. Instead, use <code>find_each</code> to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.
<code>find_each</code> to stream records in batches, so memory usage scales
with the number of '''groups''', not the number of raw rows.


=== Default Metrics in the Base Class ===
=== Default Metrics in the Base Class ===
Line 56: Line 43:
Repo X placed domain-specific math directly in the base class:
Repo X placed domain-specific math directly in the base class:


<syntaxhighlight lang="ruby">
<pre>
# WRONG — in BaseReport
# WRONG — in BaseReport
def compute_metrics(grouped)
def compute_metrics(grouped)
Line 66: Line 53:
   end
   end
end
end
</syntaxhighlight>
</pre>


This ties every subclass to one particular shape of computation. The fix is
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 '''zero domain math'''.
to move all domain math into per-report accumulator logic. The base class
contains '''zero''' domain math.


==== What "Domain Math" Means ====
==== What "Domain Math" Means ====


'''Domain math''' refers to the business-logic calculations specific to a
Domain math refers to the business-logic calculations specific to a particular report type.
particular report type — the actual formulas and aggregations that answer what
the report is trying to show.
 
It is called "domain" math because it belongs to the problem domain
(peer assessment), not to the generic pipeline machinery.
 
Each report type has its own domain math:


{| class="wikitable"
{| class="wikitable"
! Report !! Its domain math
! Report !! Its domain math
|-
|-
| Review scores
| Review scores || <code>(raw_score / max_score) * 100</code> — percentage score per reviewer per round
| <code>(raw_score / max_score) * 100</code> — percentage score per reviewer per round
|-
|-
| Avg/ranges
| Avg/ranges || <code>max, min, sum / size</code> — score aggregates across a team's reviewers
| <code>max</code>, <code>min</code>, <code>sum / size</code> — score aggregates across a team's reviewers
|-
|-
| Feedback
| Feedback || Bucket response IDs into <code>round_1</code>, <code>round_2</code>, <code>round_3</code> arrays
| Bucket response IDs into <code>round_1</code>, <code>round_2</code>, <code>round_3</code> arrays
|-
|-
| Bookmark rating
| Bookmark rating || Collect distinct bookmark IDs into a Set
| Collect distinct bookmark IDs into a <code>Set</code>
|}
|}


Notice that these are completely different in shape: one computes a percentage,
'''Rule:''' <code>BaseReport</code> only defines ''how'' to run the pipeline — stream, group, fold, finalize. The ''what'' to compute belongs entirely inside each report's own <code>accumulate</code> and <code>finalize</code> methods.
another computes min/max/avg, and another just collects IDs.
 
If <code>avg_score</code> lived in <code>BaseReport</code>, every subclass
would either inherit math it does not need — for example, a bookmark report has
no scores — or be forced to override the method just to suppress it.
 
The rule is therefore:
 
> <code>BaseReport</code> only defines '''how to run the pipeline''' — stream, group, fold, finalize.
> The '''what to compute''' belongs entirely inside each report's own <code>accumulate</code> and <code>finalize</code> methods.
 
----
 
== System Diagrams [To be updated] ==
 
=== Overall Request Flow ===
 
The diagram below shows how an incoming HTTP request travels through the
system from the controller down to the pipeline and back out as JSON.
 
Since MediaWiki does not render TikZ directly, this diagram is represented
as a text-based flow.
 
<syntaxhighlight lang="text">
HTTP Client (Front-end)
        |
        | GET /reports/response_report?assignment_id=&type=
        v
ReportsController#response_report
        |
        | params[:type]
        v
REPORT_CLASSES[type]
look up concrete class
        |
        | .new(assignment).run
        v
Concrete Report
for example, FeedbackReport
        |
        | inherits run
        v
BaseReport#run
inherited find_each streaming loop
        |
        | calls subclass methods
        v
source -> grouper -> accumulate -> finalize
        |
        | output hash
        v
render json: { ... }
        |
        | JSON response
        v
HTTP Client (Front-end)
</syntaxhighlight>
 
''Figure: End-to-end request flow for report generation''
 
=== Pipeline Internals ===
 
This diagram shows the four stages inside <code>BaseReport#run</code>.
The stages are defined by each concrete subclass; the pipeline loop itself
never changes.
 
<syntaxhighlight lang="text">
+------------------+        +------------------+
| 1. Source        | rows  | 2. Grouper      |
| AR relation      | -----> | lambda: row->key |
| streamed via    |        | e.g. reviewer_id |
| find_each        |        +------------------+
+------------------+                |
                                    | key, row
                                    v
+------------------+        +------------------+
| 4. Finalize      | state  | 3. Accumulate    |
| shape state into | <----- | fold row into    |
| output hash      |        | state            |
+------------------+        | domain math here |
        |                  +------------------+
        |
        v
Hash -> JSON
</syntaxhighlight>
 
Additional notes:
 
* <code>source</code> uses <code>includes(...)</code> where needed to avoid N+1 queries.
* <code>accumulate</code> handles scores, deduplication, bucketing, and counting depending on the report type.
 
''Figure: The four stages every report passes through inside BaseReport#run''
 
=== Class Hierarchy ===
 
This diagram shows how concrete report classes relate to <code>BaseReport</code>.
 
Solid inheritance from <code>BaseReport</code> is represented by indentation.
Composition from <code>ReviewReport</code> to its inner pipelines is represented
under the coordinator.
 
<syntaxhighlight lang="text">
BaseReport
|
|-- ReviewReport (coordinator)
|    |
|    |-- ReviewersPipeline
|    |-- ScoresPipeline
|    |-- AvgRangesPipeline
|
|-- FeedbackReport
|-- TeammateReviewReport
|-- BookmarkRatingReport
|-- BasicReport
</syntaxhighlight>
 
Legend:
 
* Direct child under <code>BaseReport</code> = inherits <code>BaseReport</code>
* Pipelines under <code>ReviewReport</code> = coordinator runs inner pipeline
* The inner pipelines also inherit <code>BaseReport</code>
 
''Figure: Class hierarchy for the report generation subsystem''
 
----


== Architecture: The Streaming Pipeline ==
== Architecture: The Streaming Pipeline ==
Line 230: Line 79:
=== Pipeline Shape ===
=== Pipeline Shape ===


All reports are built on a single pipeline template defined in
All reports are built on a single pipeline template defined in <code>Reports::BaseReport</code>:
<code>Reports::BaseReport</code>:


<syntaxhighlight lang="ruby">
<pre>
def run
def run
   state = initial_state
   state = initial_state
Line 241: Line 89:
   finalize(state)
   finalize(state)
end
end
</syntaxhighlight>
</pre>


The pipeline consists of four concerns:
The pipeline consists of four concerns:
Line 248: Line 96:
! Concern !! Responsibility
! Concern !! Responsibility
|-
|-
| '''Source'''
| Source || ActiveRecord relation streamed via <code>find_each</code>. Subclasses use <code>includes(...)</code> to eagerly load associations and prevent N+1 queries.
| ActiveRecord relation streamed via <code>find_each</code>. Subclasses use <code>includes(...)</code> to eagerly load associations and prevent N+1 queries.
|-
|-
| '''Grouper'''
| Grouper || A lambda <code>(row) -> key</code> that determines how rows are bucketed in the accumulator state.
| A lambda <code>(row) -> key</code> that determines how rows are bucketed in the accumulator state.
|-
|-
| '''Accumulate'''
| Accumulate || Folds one row into the state in place. Contains all domain-specific math for that report.
| Folds one row into the state in place. Contains all domain-specific math for that report.
|-
|-
| '''Finalize'''
| Finalize || Post-processes the finished state into the output hash. Default implementation returns state unchanged.
| Post-processes the finished state into the output hash. Default implementation returns state unchanged.
|}
|}


=== Memory Model ===
=== Memory Model ===


State grows proportional to the number of '''groups''', such as the number of
State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.
distinct reviewers, not the number of raw rows.


For example, a dataset with 10,000 responses across 20 reviewers keeps only
For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.
20 entries in the accumulator state at any point.


=== Base Class Definition ===
=== Base Class Definition ===


<syntaxhighlight lang="ruby">
<pre>
module Reports
module Reports
   class BaseReport
   class BaseReport
Line 299: Line 141:
   end
   end
end
end
</syntaxhighlight>
</pre>
 
----


== Controller ==
== Controller ==
Line 307: Line 147:
=== Dispatch Mechanism ===
=== Dispatch Mechanism ===


The controller uses a constant hash to map type strings to report classes,
The controller uses a constant hash to map type strings to report classes, replacing the <code>send(type)</code> meta-programming pattern used in Repo X.
replacing the <code>send(type)</code> meta-programming pattern used in Repo X.


<syntaxhighlight lang="ruby">
<pre>
REPORT_CLASSES = {
REPORT_CLASSES = {
   'review_response_map'          => Reports::ReviewReport,
   'review_response_map'          => Reports::ReviewReport,
   'feedback_response_map'        => Reports::FeedbackReport,
   'feedback_response_map'        => Reports::FeedbackReport, # To be implemented
   'teammate_review_response_map' => Reports::TeammateReviewReport,
   'teammate_review_response_map' => Reports::TeammateReviewReport,
   'bookmark_rating_response_map' => Reports::BookmarkRatingReport,
   'bookmark_rating_response_map' => Reports::BookmarkRatingReport, # To be implemented
   'basic'                        => Reports::BasicReport
   'basic'                        => Reports::BasicReport
}.freeze
}.freeze
Line 332: Line 171:
   render json: { type: type, assignment_id: assignment.id }.merge(data)
   render json: { type: type, assignment_id: assignment.id }.merge(data)
end
end
</syntaxhighlight>
</pre>


=== Route ===
=== Routes ===


<syntaxhighlight lang="text">
<pre>
GET  /reports/response_report?assignment_id=<id>&type=<type>
GET  /reports/response_report?assignment_id=<id>&type=<type>
POST /reports/response_report
POST /reports/response_report
</syntaxhighlight>
PATCH /review_reports/:id/update_grade
 
</pre>
----


== Report Implementations ==
== Report Implementations ==
Line 347: Line 185:
=== Review Report (<code>review_response_map</code>) ===
=== Review Report (<code>review_response_map</code>) ===


The review report is the most complex. It is implemented as a coordinator
The review report is the most complex. It is implemented as a coordinator class (<code>ReviewReport</code>) that runs three independent inner pipelines and merges their results.
class (<code>ReviewReport</code>) that runs three independent inner pipelines
and merges their results.


{| class="wikitable"
{| class="wikitable"
! Pipeline !! Source !! Groups by !! Produces
! Pipeline !! Source !! Groups by !! Produces
|-
|-
| <code>ReviewersPipeline</code>
| ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list
| <code>ReviewResponseMap</code>
| <code>reviewer_id</code>
| Sorted reviewer list
|-
|-
| <code>ScoresPipeline</code>
| ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee
| <code>Response</code> JOIN map
| <code>reviewer_id</code>
| Score percentage per round/reviewee
|-
|-
| <code>AvgRangesPipeline</code>
| AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round
| <code>Response</code> JOIN map
| <code>[reviewee_id, round]</code>
| Max/min/avg per team/round
|}
|}


==== N+1 Fix: Precomputed Max Question Score ====
==== N+1 Fix: Precomputed Max Question Score ====


Repo X called <code>response.maximum_score</code> inside the accumulation loop.
Repo X called <code>response.maximum_score</code> inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a <code>round -> max_question_score</code> map with a single query before the pipeline runs:


This method internally calls:
<pre>
 
<syntaxhighlight lang="ruby">
response_assignment.assignment_questionnaires
  .find_by(used_in_round: round)
  .questionnaire
</syntaxhighlight>
 
This resulted in one query per response.
 
In Repo Y, both score pipelines precompute a
<code>round -> max_question_score</code> map with a single query before the
pipeline runs:
 
<syntaxhighlight lang="ruby">
def precompute_max_q_scores
def precompute_max_q_scores
   AssignmentQuestionnaire
   AssignmentQuestionnaire
Line 396: Line 209:
     .to_h
     .to_h
end
end
</syntaxhighlight>


The result, for example <code>{nil => 10, 1 => 10, 2 => 5}</code>, is stored in
# Used inside accumulate:
<code>@max_q_score</code> and used as a lookup inside <code>accumulate</code>:
 
<syntaxhighlight lang="ruby">
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)
</syntaxhighlight>
</pre>


==== Sample Response ====
==== Sample Response ====


<syntaxhighlight lang="json">
<pre>
{
{
   "type": "review_response_map",
   "type": "review_response_map",
Line 416: Line 225:
   ],
   ],
   "review_scores": { "5": { "1": { "3": 87.5 } } },
   "review_scores": { "5": { "1": { "3": 87.5 } } },
   "avg_and_ranges": { "3": { "1": { "max": 92.0,
   "avg_and_ranges": { "3": { "1": { "max": 92.0, "min": 75.0, "avg": 83.5 } } }
                                    "min": 75.0,
                                    "avg": 83.5 } } }
}
}
</syntaxhighlight>
</pre>
 
----


=== Feedback Report (<code>feedback_response_map</code>) ===
=== Feedback Report (<code>feedback_response_map</code>) ===


Produces the list of authors and the IDs of review responses that received
Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.
author feedback, bucketed by round for varying-rubric assignments.
 
<syntaxhighlight lang="ruby">
def source
  Response
    .joins(:response_map)
    .where(
      response_maps: { type: 'ReviewResponseMap',
                      reviewed_object_id: @assignment.id }
    )
    .order(created_at: :desc)
end
 
def grouper      = ->(r) { [r.map_id, r.round] }
def initial_state = { seen: Set.new, round_1: [],
                      round_2: [], round_3: [], all: [] }
 
def accumulate(state, key, response)
  return if state[:seen].include?(key)
  state[:seen].add(key)
  if @assignment.varying_rubrics_by_round?
    case response.round
    when 1 then state[:round_1] << response.id
    when 2 then state[:round_2] << response.id
    when 3 then state[:round_3] << response.id
    end
  else
    state[:all] << response.id
  end
end
</syntaxhighlight>
 
Deduplication uses a <code>Set</code>, which gives O(1) lookup, rather than the
array-based <code>seen_map_round_keys.include?</code> from Repo X, which gives
O(n) lookup.
 
Authors are fetched once in <code>finalize</code>, not inside the stream.


==== End-to-End Execution Flow ====
Deduplication uses a Set (O(1) lookup), rather than the array-based <code>seen_map_round_keys.include?</code> from Repo X (O(n) lookup). Authors are fetched once in <code>finalize</code>, not inside the stream.
 
The <code>run</code> method is '''inherited from <code>BaseReport</code>'''.
<code>FeedbackReport</code> never defines it.
 
Calling <code>FeedbackReport.new(assignment).run</code> triggers the following
sequence:
 
<syntaxhighlight lang="text">
ReportsController
  REPORT_CLASSES['feedback_response_map'].new(assignment).run
    |
    |  (inherited from BaseReport)
    v
  BaseReport#run
    |
    |-- Step 1: state = initial_state
    |      => { seen: Set.new,
    |            round_1: [], round_2: [], round_3: [], all: [] }
    |
    |-- Step 2: source.find_each(batch_size: 500)
    |      => Response.joins(:response_map)
    |                  .where(type: 'ReviewResponseMap',
    |                        reviewed_object_id: assignment.id)
    |                  .order(created_at: :desc)
    |          streams Response records newest-first, in batches
    |
    |-- Step 3: for each Response row:
    |      key = grouper.call(row)
    |          => [row.map_id, row.round]  e.g. [42, 1]
    |
    |      accumulate(state, key, row)
    |          => skip if state[:seen] already has [map_id, round]
    |              (keeps only the latest response per map per round
    |              because source is ordered newest-first)
    |          => otherwise: add key to :seen, then bucket row.id:
    |                round == 1  =>  state[:round_1] << row.id
    |                round == 2  => state[:round_2] << row.id
    |                round == 3  =>  state[:round_3] << row.id
    |                (or state[:all] if single-rubric assignment)
    |
    |-- Step 4: finalize(state)
            => fetch_authors  (one query: teams -> users -> participants)
            => if varying_rubrics_by_round?
                return { authors: [...],
                          review_response_ids: {
                            round_1: [...], round_2: [...], round_3: [...] } }
              else
                return { authors: [...],
                          review_response_ids: [...] }
</syntaxhighlight>
 
The key point is that <code>FeedbackReport</code> only defines the four pieces
the pipeline needs:
 
* <code>source</code>
* <code>grouper</code>
* <code>initial_state</code>
* <code>accumulate</code>
* <code>finalize</code>
 
Ruby's inheritance mechanism means calling <code>.run</code> on a
<code>FeedbackReport</code> instance automatically executes
<code>BaseReport#run</code>, which calls back into <code>FeedbackReport</code>'s
implementations of those methods.


==== Sample Response (varying rubrics) ====
==== Sample Response (varying rubrics) ====


<syntaxhighlight lang="json">
<pre>
{
{
   "type": "feedback_response_map",
   "type": "feedback_response_map",
Line 541: Line 245:
   }
   }
}
}
</syntaxhighlight>
</pre>
 
----


=== Teammate Review Report (<code>teammate_review_response_map</code>) ===
=== Teammate Review Report (<code>teammate_review_response_map</code>) ===


Streams <code>TeammateReviewResponseMap</code> records grouped by
Streams <code>TeammateReviewResponseMap</code> records grouped by <code>reviewer_id</code>. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.
<code>reviewer_id</code>.


The first occurrence per reviewer is kept using deduplication via early return
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend.
if the key already exists in state. Reviewer associations are eagerly loaded.


==== Sample Response ====
==== Sample Response ====


<syntaxhighlight lang="json">
<pre>
{
{
   "type": "teammate_review_response_map",
   "type": "teammate_review_response_map",
Line 563: Line 263:
   ]
   ]
}
}
</syntaxhighlight>
</pre>
 
=== Bookmark Rating Report (<code>bookmark_rating_response_map</code>) ===
 
To be implemented.
 
=== Basic Report (<code>basic</code>) ===


----
Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no <code>type</code> parameter is provided.


=== Bookmark Rating Report (<code>bookmark_rating_response_map</code>) ===
==== Sample Response ====
 
<pre>
{
  "type": "basic",
  "assignment_id": 1,
  "assignment": {
    "id": 1, "name": "Project 1",
    "num_review_rounds": 2,
    "varying_rubrics_by_round": true
  }
}
</pre>
 
=== Course Grade Summary ===


Streams <code>BookmarkRatingResponseMap</code> records, accumulating distinct
Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in <code>CourseReportsController</code>, which includes <code>PenaltyHelper</code>.
bookmark IDs into a <code>Set</code>.


Project topics are fetched once in <code>finalize</code>.
'''Route:'''


==== Bug Fixed During Port ====
<pre>
GET /courses/:id/course_report/grade_summary
</pre>


The model's <code>bookmark_response_report</code> in Repo Y was incorrectly
'''Key computations:'''
calling:


<syntaxhighlight lang="ruby">
* '''Peer score''' — <code>precompute_peer_scores</code> bulk-loads all <code>ReviewResponseMap</code> records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from <code>ReviewGrade#grade_for_reviewer</code>; defaults to 1.0 if absent.
.pluck(:reviewed_object_id)
* '''Instructor grade''' — <code>team.grade_for_submission</code> minus the late penalty from <code>PenaltyHelper#get_penalty(participant_id)[:submission]</code>. Returns <code>nil</code> when no grade has been set.
</syntaxhighlight>
* '''Penalty guard''' — <code>get_penalty</code> returns <code>{ submission: 0, review: 0, meta_review: 0 }</code> immediately when the assignment has no late policy (<code>@penalty_per_unit</code> is nil), avoiding constant-lookup errors for <code>MetareviewResponseMap</code>.
* '''Calibrated assignments excluded''' — assignments where <code>is_calibrated: true</code> are filtered out.
* '''Empty assignments excluded''' — assignments with no participants are rejected from the response.


This returns assignment IDs, since <code>reviewed_object_id</code> is the
<pre>
foreign key to <code>Assignment</code>.
def precompute_peer_scores(assignment_ids, team_ids)
  return {} if team_ids.empty?


Bookmark IDs are stored in <code>reviewee_id</code>. Therefore, this was fixed
  maps = ReviewResponseMap
to:
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)
    .includes(responses: :scores)


<syntaxhighlight lang="ruby">
  reviewer_grades = ReviewGrade
.pluck(:reviewee_id)
    .where(participant_id: maps.map(&:reviewer_id).uniq)
</syntaxhighlight>
    .index_by(&:participant_id)


==== Sample Response ====
  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }
  maps.each do |map|
    w = reviewer_grades[map.reviewer_id]&.grade_for_reviewer || 1.0
    map.responses.select(&:is_submitted).each do |resp|
      max = resp.maximum_score
      next if max.zero?
      pct = resp.aggregate_questionnaire_score.to_f / max * 100
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w
    end
  end
 
  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }
end
</pre>
 
'''Sample Response:'''


<syntaxhighlight lang="json">
<pre>
{
{
   "type": "bookmark_rating_response_map",
   "course_id": 1,
   "bookmark_ids": [10, 14, 22],
  "course_name": "CSC 517",
   "topics": [{ "id": 3, "topic_name": "Machine Learning" }]
   "assignments": [{ "id": 10, "name": "Project 1", "has_topics": true }],
   "rows": [
    {
      "user_id": 5,
      "user_name": "alice",
      "assignments": [
        {
          "assignment_id": 10,
          "assignment_name": "Project 1",
          "topic": "Machine Learning",
          "peer_score": 87.5,
          "instructor_grade": 85.0
        }
      ],
      "final_grade": 85.0
    }
  ]
}
}
</syntaxhighlight>
</pre>
 
=== Course All Reviews ===


----
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.


=== Basic Report (<code>basic</code>) ===
'''Route:'''


Returns minimal assignment metadata.
<pre>
GET /courses/:id/course_report/all_reviews
</pre>


No streaming is required since all data comes from the already-loaded
'''Key computations:'''
<code>Assignment</code> object.


This report is used as the default when no type parameter is provided.
* '''Teammate scores''' — <code>precompute_teammate_scores</code> bulk-loads <code>TeammateReviewResponseMap</code> records and returns <code>{ participant_id => "avg%" }</code>.
* '''Teammate count''' — <code>precompute_teammate_counts</code> counts distinct teammates per user across all assignments in the course.


==== Sample Response ====
'''Sample Response:'''


<syntaxhighlight lang="json">
<pre>
{
{
   "type": "basic",
   "course_id": 1,
   "assignment_id": 1,
   "course_name": "CSC 517",
   "assignment": {
   "assignments": [{ "id": 10, "name": "Project 1" }],
    "id": 1, "name": "Project 1",
  "rows": [
     "num_review_rounds": 2,
     {
    "varying_rubrics_by_round": true
      "user_id": 5,
   }
      "user_name": "alice",
      "teammate_count": 2,
      "assignments": [
        { "assignment_id": 10, "assignment_name": "Project 1", "teammate_review": "83%" }
      ],
      "aggregate": "83%"
    }
   ]
}
}
</syntaxhighlight>
</pre>
 
== ReviewGrade Model ==
 
Stores an instructor's grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.
 
'''Migrations:'''
 
<pre>
# 20260810135129_create_review_grades.rb
create_table :review_grades, if_not_exists: true do |t|
  t.integer  :participant_id, null: false, index: { unique: true }
  t.float    :grade_for_reviewer
  t.text    :comment_for_reviewer
  t.integer  :grader_id
  t.timestamps
end
</pre>
 
<pre>
# 20260813000001_add_instructor_grade_scores_to_assignments.rb
add_column :assignments, :instructor_grade_min_score, :integer
add_column :assignments, :instructor_grade_max_score, :integer
</pre>
 
These two columns back the <code>instructor_grade_min_score</code> and <code>instructor_grade_max_score</code> fields exposed in <code>assignment_params</code>. The frontend's GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.
 
'''Endpoint:'''
 
<pre>
PATCH /review_reports/:id/update_grade
# params: { grade_for_reviewer:, comment_for_reviewer: }
# :id is the participant_id (AssignmentParticipant)
</pre>
 
'''Known limitation:''' <code>ReviewGrade</code> 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.
 
== Frontend Architecture and Renders ==
 
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.
 
=== Assignments ===
 
Few changes have been done to the assignment pages to include the reports and instructor grading scales.
 
<gallery mode="nolines" widths="500px" heights="350px" perrow="2">
File:Manage_Assignments_tab.png|This is the Assignments tab for an instructor/admin.
File:Assignment_Review_Strategy_tab.png|Review the strategy tab for an assignment where min and max scores for the reviews can be set.
File:Assignment_Etc_tab.png|The implemented reports have been listed in the View Reports button.
</gallery>
 
=== ReviewReportPage ===
 
File: <code>src/pages/ReviewReportPage/ReviewReportPage.tsx</code>
 
* Rounds 1–5 visibility toggled per reviewer row
* '''MetricsChart''' — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely
* Status color coding per reviewer, including brown pre-population on reload from <code>reviewer_grades</code> in the fetch response
* Scores awarded and average score display
* '''Grade/comment save''' via <code>PATCH /review_reports/:id/update_grade</code>
* CSV export, column sorting, search filter
* Summary modal per reviewer
* Color cutoffs at 90/80/70/60 (A/B/C/D scale) via <code>getColorClass</code>
* Dynamic color bands via <code>scoreToColor()</code> — <code>k = min(maxScore, 10)</code> HSL bands, green → red spectrum
* Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max
* Review metrics column narrowed; <code>barGap=1</code>; <code>slotWidth=75px</code> between groups
 
<gallery mode="nolines" widths="500px" heights="350px" perrow="2">
File:Review_Report_1.png|The Review Report page showing reviewer scores and reviewer grade for an assignment with a collapsible legend.
File:Review_Report_2.png|The Review Report page (with sticky headers) showing reviewer scores and visual metrics comparing the reviewer and average.
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.
</gallery>
 
=== TeammateReviewReportPage ===
 
File: <code>src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx</code>
 
* Team grouping column
* View modal showing scores per reviewee
* Assignment name heading
* Compact layout: <code>p-3</code> container, <code>line-height: 1.4</code>, padding <code>6px 24px 6px 8px</code> on th/td
* <code>tableStyle={{ width: "fit-content" }}</code> — table shrinks to content, no full-width stretch
* "Reviews" header on the view-link column
* CSS scoped under <code>.teammate-review-report-page</code> (does not affect Review Report)
* Status color <code>#dc3545</code> for below-threshold scores
 
<gallery mode="nolines" widths="500px" heights="350px" perrow="2">
File:Teammate_Review_Report_1.png|The Teammate Review Report page showing the status of reviews required by teammates about each other.
File: Teammate_Review_Report_2.png|Detailed teammate reviews page redirected from the previous Teammate Review Report page view button
</gallery>
 
=== Courses ===
 
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.
 
<gallery mode="nolines" widths="500px" heights="350px" perrow="2">
File: Manage_Courses_tab.png |This is the courses tab accessed by instructors/admin.
</gallery>
 
 
=== Course Report Pages ===
 
Accessible from the course list: expand a course → assignment row → "View Review Report" button.
 
{| class="wikitable"
! Page !! Frontend Route !! Backend Endpoint
|-
| <code>CourseGradeSummaryPage</code> || <code>courses/:courseId/course-report/grade-summary</code> || <code>GET /courses/:id/course_report/grade_summary</code>
|-
| <code>CourseAllReviewsPage</code> || <code>courses/:courseId/course-report/all-reviews</code> || <code>GET /courses/:id/course_report/all_reviews</code>
|}
 
Both pages render a heat-grid layout where each cell's color is determined by the value's position in the observed data range (not a fixed rubric max).
 
<gallery mode="nolines" widths="500px" heights="350px" perrow="2">
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.
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.
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.
</gallery>
 
=== Shared Utilities & Heat Grid ===
 
'''<code>src/utils/heatgridUtils.ts</code>'''
 
* <code>getHeatColorClass(value, dataMin, dataMax)</code> — relative coloring, maps value to a CSS class based on its position between <code>dataMin</code> and <code>dataMax</code>.
* <code>getColorClass(score)</code> — absolute grade-scale coloring (90/80/70/60 cutoffs).
 
'''<code>src/utils/reviewTypes.ts</code>'''
 
* Exports <code>ReviewData</code> and <code>SectionHeaderData</code> interfaces. Re-exported from <code>ViewTeamGrades/App.tsx</code>.
 
'''Heat grid CSS''' (in <code>custom.scss</code>, global, after Bootstrap import, with <code>!important</code>):
 
<pre>
.c5 { background: #1a7a4a; color: #fff; }  /* A — high */
.c4 { background: #5cb85c; }              /* B */
.c3 { background: #f0ad4e; }              /* C */
.c2 { background: #d9534f; color: #fff; }  /* D */
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */
.c0 { background: #e0e0e0; }              /* no data */
.cf { background: #f8f8f8; }              /* empty */
</pre>
 
 
=== Frontend Routes (App.tsx) ===
 
<pre>
courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage
</pre>
 
Dropdown on the Review Report page: ''Review Report'' | ''Teammate Review Report''. (Per Team report removed — no counterpart in Repo X.)


----
== Authorization Changes ==
 
{| class="wikitable"
! Controller !! Change
|-
| <code>AssignmentsController</code> || Added <code>action_allowed? → current_user_has_instructor_privileges?</code>. Previously defaulted to <code>true</code> — any user could CRUD assignments.
|-
| <code>CoursesController#index</code> || Scoped to <code>Course.where(instructor_id: current_user.id)</code>. Admins still see <code>Course.all</code>.
|-
| <code>AssignmentsController#index</code> || Scoped to instructor's own assignments plus assignments under their courses. Admins see <code>Assignment.all</code>.
|-
| <code>ReportsController</code> || <code>action_allowed?</code> now checks <code>current_user_has_admin_privileges? || current_user_teaching_staff_of_assignment?</code>. Removed overly broad <code>instructor_privileges?</code>.
|-
| <code>CourseReportsController</code> || New controller; auth: <code>current_user_has_instructor_privileges?</code>.
|}
 
== RSpec Test Coverage ==
 
{| class="wikitable"
! Spec file !! Covers
|-
| <code>spec/requests/api/v1/review_reports_controller_spec.rb</code> || Response report fetch, update_grade endpoint
|-
| <code>spec/requests/api/v1/review_grade_conflicts_spec.rb</code> || ReviewGrade conflict detection endpoint
|-
| <code>spec/requests/api/v1/course_reports_controller_spec.rb</code> || grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews
|-
| <code>spec/requests/api/v1/teammate_review_report_spec.rb</code> || Teammate review report endpoint
|-
| <code>spec/models/response_map_spec.rb</code> || Added <code>.compute_average_reviewer_score</code> describe block (8 cases, uses instance_double)
|-
| <code>spec/requests/api/v1/assignment_controller_spec.rb</code> || show/update with AQ data, instructor_grade_min/max fields
|}
 
== Pending Roadmap ==
 
Items identified in the 2026-08-19 team meeting, not yet implemented:
 
* Info button on the heat grid explaining relative coloring (displayed max ≠ 100)
* Toggle to show/hide the metrics/chart column
* "Volume" column header label derived from active metrics (not hardwired)
* Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell
* Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round
* Grade change warning banner when instructor lowers max points below an already-assigned score
 
Previously completed from the same meeting:
 
* Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)
* Dynamic color bands via <code>scoreToColor()</code>
* "Question" column header renamed to "Item" in heatgrid and feedback table
* <code>/100</code> hardcoded scale label removed from GradeCommentCell
* Avg bar suppressed for rounds where reviewer did not participate


== File Structure ==
== File Structure ==


<syntaxhighlight lang="text">
<pre>
app/
app/
   controllers/
   controllers/
     reports_controller.rb       Entry point, REPORT_CLASSES dispatch
     reports_controller.rb               Entry point, REPORT_CLASSES dispatch
    course_reports_controller.rb        Grade summary & all-reviews endpoints  [NEW]
    assignments_controller.rb          show/update with render body: fix
   helpers/
   helpers/
     report_formatter_helper.rb   Empty namespace (logic moved to services)
     report_formatter_helper.rb         Empty namespace (logic moved to services)
    penalty_helper.rb                  get_penalty with nil-guard early return
   services/
   services/
     reports/
     reports/
       base_report.rb             Abstract pipeline template
       base_report.rb                   Abstract pipeline template
       review_report.rb           3-pipeline coordinator
       review_report.rb                 3-pipeline coordinator
       feedback_report.rb         Single pipeline, round bucketing
       feedback_report.rb               Single pipeline, round bucketing
       teammate_review_report.rb Single pipeline
       teammate_review_report.rb         Single pipeline
       bookmark_rating_report.rb Single pipeline
       bookmark_rating_report.rb         Single pipeline
       basic_report.rb           Simple struct
       basic_report.rb                   Simple struct
   models/
   models/
    review_grade.rb                    Grade & comment per reviewer  [NEW]
    response.rb                        maximum_score nil guard
     review_response_map.rb
     review_response_map.rb
      .review_response_report class method
     feedback_response_map.rb
     feedback_response_map.rb
      .feedback_response_report class method
     teammate_review_response_map.rb
     teammate_review_response_map.rb
      .teammate_response_report class method
     bookmark_rating_response_map.rb
     bookmark_rating_response_map.rb
      .bookmark_response_report (bug fixed)
</syntaxhighlight>


----
db/
  migrate/
    20260810135129_create_review_grades.rb                      [NEW]
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]
 
spec/
  requests/api/v1/
    review_reports_controller_spec.rb  [NEW]
    review_grade_conflicts_spec.rb      [NEW]
    course_reports_controller_spec.rb  [NEW]
    teammate_review_report_spec.rb      [NEW]
    assignment_controller_spec.rb      Updated
  models/
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]
 
# Frontend (reimplementation-front-end repo)
src/
  pages/
    ReviewReportPage/ReviewReportPage.tsx        [NEW]
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]
    CourseAllReviewsPage/CourseAllReviewsPage.tsx          [NEW]
  utils/
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]
  styles/
    custom.scss                        .c0–.c5, .cf heat-grid classes
</pre>


== Blocked Report Types ==
== Blocked Report Types ==


The following report types exist in Repo X but cannot yet be implemented in
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.
Repo Y due to missing database tables or models.
 
Each is ready to be added once its dependency is ported.


{| class="wikitable"
{| class="wikitable"
! Report Type !! Missing Dependency !! Repo X Location
! Report Type !! Missing Dependency !! Repo X Location
|-
|-
| <code>calibration</code>
| calibration || <code>calibrate_to</code> column on response_maps || report_formatter_helper.rb
| <code>calibrate_to</code> column on <code>response_maps</code>
| <code>report_formatter_helper.rb</code>
|-
|-
| <code>self_review</code>
| self_review || <code>SelfReviewResponseMap</code> model || self_review_response_map.rb
| <code>SelfReviewResponseMap</code> model
| <code>self_review_response_map.rb</code>
|-
|-
| <code>survey</code>
| survey || <code>survey_deployments</code> table || survey_response_map.rb
| <code>survey_deployments</code> table
| <code>survey_response_map.rb</code>
|-
|-
| <code>quiz</code>
| quiz || <code>quiz_responses</code> table || quiz_response_map.rb
| <code>quiz_responses</code> table
| <code>quiz_response_map.rb</code>
|-
|-
| <code>answer_tagging</code>
| answer_tagging || <code>tag_prompt_deployments</code>, <code>answer_tags</code> tables || tag_prompt_deployment.rb
| <code>tag_prompt_deployments</code>, <code>answer_tags</code> tables
| <code>tag_prompt_deployment.rb</code>
|}
|}


To add a blocked report once its dependencies are available:
To add a blocked report once its dependencies are available:


# Create <code>app/services/reports/&lt;name&gt;_report.rb</code> inheriting <code>BaseReport</code>.
# Create <code>app/services/reports/<name>_report.rb</code> inheriting <code>BaseReport</code>.
# Define <code>source</code>, <code>grouper</code>, <code>initial_state</code>, <code>accumulate</code>, and <code>finalize</code>.
# Define <code>source</code>, <code>grouper</code>, <code>initial_state</code>, <code>accumulate</code>, and <code>finalize</code>.
# Add an entry to <code>ReportsController::REPORT_CLASSES</code>.
# Add an entry to <code>ReportsController::REPORT_CLASSES</code>.
----


== Comparison with Repo X ==
== Comparison with Repo X ==
Line 703: Line 684:
! Concern !! Repo X !! Repo Y
! Concern !! Repo X !! Repo Y
|-
|-
| Output format
| Output format || ERB instance variables (<code>@reviewers</code>, <code>@review_scores</code>) || JSON hash from <code>report.run</code>
| ERB instance variables, such as <code>@reviewers</code> and <code>@review_scores</code>
| JSON hash from <code>report.run</code>
|-
|-
| Loading strategy
| Loading strategy || All records loaded into arrays at once || <code>find_each</code> batched streaming
| All records loaded into arrays at once
| <code>find_each</code> batched streaming
|-
|-
| Metrics location
| Metrics location || <code>compute_metrics</code> in helper base || Each report owns <code>accumulate</code> and <code>finalize</code>
| <code>compute_metrics</code> in helper base
| Each report owns <code>accumulate</code> and <code>finalize</code>
|-
|-
| Dispatch
| Dispatch || <code>send(@type.underscore, params, session)</code> || <code>REPORT_CLASSES[type].new(assignment).run</code>
| <code>send(@type.underscore, params, session)</code>
| <code>REPORT_CLASSES[type].new(assignment).run</code>
|-
|-
| N+1 on scores
| N+1 on scores || <code>response.maximum_score</code> per row — questionnaire lookup each time || Precomputed <code>round→max_score</code> map, one query before pipeline
| <code>response.maximum_score</code> per row — questionnaire lookup each time
| Precomputed <code>round->max_score</code> map, one query before pipeline
|-
|-
| Deduplication
| Deduplication || <code>Array#include?</code> — O(n) per check || <code>Set#include?</code> — O(1) per check
| <code>Array#include?</code> — O(n) per check
| <code>Set#include?</code> — O(1) per check
|-
|-
| Additional features
| Course reports || Grade book & review summary in ERB views || <code>CourseReportsController</code> with JSON API endpoints
| LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging
|-
| Not yet ported; blocked on schema
| Reviewer grading || ReviewGrade on Assignment model || Dedicated <code>ReviewGrade</code> model, <code>update_grade</code> endpoint
|-
| Additional features || LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging || CSV export ✓; others blocked on schema
|}
|}


----
== Author ==
== Author ==


Line 738: Line 708:
! Name !! Role
! Name !! Role
|-
|-
| '''Aanand Sreekumaran Nair Jayakumari'''
| Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework
| Project contributor / developer
|-
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend
|}
|}

Latest revision as of 13:50, 27 August 2026

Reports and Course Summary

Design and Implementation Documentation

Expertiza Reimplementation Back-End

Overview

The reporting subsystem was ported from the original Expertiza codebase (referred to as Repo X) into the reimplemented Rails API back-end (referred to as Repo Y) and redesigned in the process.

Repo X used a Rails helper module (ReportFormatterHelper) that assigned instance variables, such as @reviewers and @review_scores, for ERB views.

Since Repo Y is a JSON API with no views, instance variables are not applicable. The architecture was therefore redesigned around a streaming reduce pipeline that returns plain Ruby hashes rendered as JSON.

Design Goals

  • Avoid loading entire result sets into memory at once.
  • Keep domain-specific computation out of the base class.
  • Make each report type independently composable and testable.
  • Fix N+1 query patterns present in Repo X.

Anti-Patterns Addressed

The design directly addresses two anti-patterns identified during architectural review of Repo X.

The fetch_responses Anti-Pattern

Repo X loaded all response records into an unnamed, ad-hoc array before processing:

# WRONG
responses = fetch_responses   # full memory load
grouped   = group(responses)
metrics   = compute_metrics(grouped)

This forces the entire result set into Ruby memory, prevents streaming, and makes the intermediate structure implicit.

The fix is to never materialise all rows at once. Instead, use find_each to stream records in batches, so memory usage scales with the number of groups, not the number of raw rows.

Default Metrics in the Base Class

Repo X placed domain-specific math directly in the base class:

# WRONG — in BaseReport
def compute_metrics(grouped)
  grouped.transform_values do |responses|
    {
      count:     responses.size,
      avg_score: responses.map(&:score).sum / responses.size.to_f
    }
  end
end

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 zero domain math.

What "Domain Math" Means

Domain math refers to the business-logic calculations specific to a particular report type.

Report Its domain math
Review scores (raw_score / max_score) * 100 — percentage score per reviewer per round
Avg/ranges max, min, sum / size — score aggregates across a team's reviewers
Feedback Bucket response IDs into round_1, round_2, round_3 arrays
Bookmark rating Collect distinct bookmark IDs into a Set

Rule: BaseReport only defines how to run the pipeline — stream, group, fold, finalize. The what to compute belongs entirely inside each report's own accumulate and finalize methods.

Architecture: The Streaming Pipeline

Pipeline Shape

All reports are built on a single pipeline template defined in Reports::BaseReport:

def run
  state = initial_state
  source.find_each(batch_size: 500) do |row|
    accumulate(state, grouper.call(row), row)
  end
  finalize(state)
end

The pipeline consists of four concerns:

Concern Responsibility
Source ActiveRecord relation streamed via find_each. Subclasses use includes(...) to eagerly load associations and prevent N+1 queries.
Grouper A lambda (row) -> key that determines how rows are bucketed in the accumulator state.
Accumulate Folds one row into the state in place. Contains all domain-specific math for that report.
Finalize Post-processes the finished state into the output hash. Default implementation returns state unchanged.

Memory Model

State grows proportional to the number of groups (such as distinct reviewers), not the number of raw rows.

For example, a dataset with 10,000 responses across 20 reviewers keeps only 20 entries in the accumulator state at any point.

Base Class Definition

module Reports
  class BaseReport
    def initialize(assignment)
      @assignment = assignment
    end

    def run
      state = initial_state
      source.find_each(batch_size: 500) do |row|
        accumulate(state, grouper.call(row), row)
      end
      finalize(state)
    end

    private

    def source        = raise NotImplementedError
    def grouper       = raise NotImplementedError
    def initial_state = raise NotImplementedError

    def accumulate(_state, _key, _row)
      raise NotImplementedError
    end

    def finalize(state) = state
  end
end

Controller

Dispatch Mechanism

The controller uses a constant hash to map type strings to report classes, replacing the send(type) meta-programming pattern used in Repo X.

REPORT_CLASSES = {
  'review_response_map'          => Reports::ReviewReport,
  'feedback_response_map'        => Reports::FeedbackReport, # To be implemented
  'teammate_review_response_map' => Reports::TeammateReviewReport,
  'bookmark_rating_response_map' => Reports::BookmarkRatingReport, # To be implemented
  'basic'                        => Reports::BasicReport
}.freeze

def response_report
  type         = params.dig(:report, :type) || params[:type] || 'basic'
  report_class = REPORT_CLASSES[type]

  unless report_class
    return render json: { error: "Unknown report type: #{type}" },
                  status: :unprocessable_entity
  end

  assignment = Assignment.find(params[:assignment_id] || params[:id])
  data = report_class.new(assignment).run
  render json: { type: type, assignment_id: assignment.id }.merge(data)
end

Routes

GET  /reports/response_report?assignment_id=<id>&type=<type>
POST /reports/response_report
PATCH /review_reports/:id/update_grade

Report Implementations

Review Report (review_response_map)

The review report is the most complex. It is implemented as a coordinator class (ReviewReport) that runs three independent inner pipelines and merges their results.

Pipeline Source Groups by Produces
ReviewersPipeline ReviewResponseMap reviewer_id Sorted reviewer list
ScoresPipeline Response JOIN map reviewer_id Score percentage per round/reviewee
AvgRangesPipeline Response JOIN map [reviewee_id, round] Max/min/avg per team/round

N+1 Fix: Precomputed Max Question Score

Repo X called response.maximum_score inside the accumulation loop, resulting in one query per response. In Repo Y, both score pipelines precompute a round -> max_question_score map with a single query before the pipeline runs:

def precompute_max_q_scores
  AssignmentQuestionnaire
    .joins(:questionnaire)
    .where(assignment_id: @assignment.id)
    .pluck(:used_in_round, 'questionnaires.max_question_score')
    .to_h
end

# Used inside accumulate:
max_score = total_wt * (@max_q_score[round] || @max_q_score[nil] || 1)

Sample Response

{
  "type": "review_response_map",
  "assignment_id": 1,
  "reviewers": [
    { "id": 5, "user_id": 2, "name": "alice",
      "full_name": "Alice Smith", "handle": "alice" }
  ],
  "review_scores": { "5": { "1": { "3": 87.5 } } },
  "avg_and_ranges": { "3": { "1": { "max": 92.0, "min": 75.0, "avg": 83.5 } } }
}

Feedback Report (feedback_response_map)

Produces the list of authors and the IDs of review responses that received author feedback, bucketed by round for varying-rubric assignments.

Deduplication uses a Set (O(1) lookup), rather than the array-based seen_map_round_keys.include? from Repo X (O(n) lookup). Authors are fetched once in finalize, not inside the stream.

Sample Response (varying rubrics)

{
  "type": "feedback_response_map",
  "authors": [{ "id": 7, "name": "bob", "full_name": "Bob Jones" }],
  "review_response_ids": {
    "round_1": [12, 15], "round_2": [18], "round_3": []
  }
}

Teammate Review Report (teammate_review_response_map)

Streams TeammateReviewResponseMap records grouped by reviewer_id. The first occurrence per reviewer is kept using deduplication via early return. Reviewer associations are eagerly loaded.

See the TeammateReviewReportPage section for the corresponding frontend.

Sample Response

{
  "type": "teammate_review_response_map",
  "reviewers": [
    { "reviewer_id": 5, "user_id": 2,
      "name": "alice", "full_name": "Alice Smith" }
  ]
}

Bookmark Rating Report (bookmark_rating_response_map)

To be implemented.

Basic Report (basic)

Returns minimal assignment metadata. No streaming is required since all data comes from the already-loaded Assignment object. Used as the default when no type parameter is provided.

Sample Response

{
  "type": "basic",
  "assignment_id": 1,
  "assignment": {
    "id": 1, "name": "Project 1",
    "num_review_rounds": 2,
    "varying_rubrics_by_round": true
  }
}

Course Grade Summary

Instructor-facing endpoint that aggregates peer scores and instructor grades per student per assignment within a course. Lives in CourseReportsController, which includes PenaltyHelper.

Route:

GET /courses/:id/course_report/grade_summary

Key computations:

  • Peer scoreprecompute_peer_scores bulk-loads all ReviewResponseMap records for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes from ReviewGrade#grade_for_reviewer; defaults to 1.0 if absent.
  • Instructor gradeteam.grade_for_submission minus the late penalty from PenaltyHelper#get_penalty(participant_id)[:submission]. Returns nil when no grade has been set.
  • Penalty guardget_penalty returns { submission: 0, review: 0, meta_review: 0 } immediately when the assignment has no late policy (@penalty_per_unit is nil), avoiding constant-lookup errors for MetareviewResponseMap.
  • Calibrated assignments excluded — assignments where is_calibrated: true are filtered out.
  • Empty assignments excluded — assignments with no participants are rejected from the response.
def precompute_peer_scores(assignment_ids, team_ids)
  return {} if team_ids.empty?

  maps = ReviewResponseMap
    .where(reviewed_object_id: assignment_ids, reviewee_id: team_ids)
    .includes(responses: :scores)

  reviewer_grades = ReviewGrade
    .where(participant_id: maps.map(&:reviewer_id).uniq)
    .index_by(&:participant_id)

  weighted = Hash.new { |h, k| h[k] = { sum: 0.0, weight: 0.0 } }
  maps.each do |map|
    w = reviewer_grades[map.reviewer_id]&.grade_for_reviewer || 1.0
    map.responses.select(&:is_submitted).each do |resp|
      max = resp.maximum_score
      next if max.zero?
      pct = resp.aggregate_questionnaire_score.to_f / max * 100
      weighted[[map.reviewed_object_id, map.reviewee_id]][:sum]    += pct * w
      weighted[[map.reviewed_object_id, map.reviewee_id]][:weight] += w
    end
  end

  weighted.transform_values { |v| (v[:sum] / v[:weight]).round(2) }
end

Sample Response:

{
  "course_id": 1,
  "course_name": "CSC 517",
  "assignments": [{ "id": 10, "name": "Project 1", "has_topics": true }],
  "rows": [
    {
      "user_id": 5,
      "user_name": "alice",
      "assignments": [
        {
          "assignment_id": 10,
          "assignment_name": "Project 1",
          "topic": "Machine Learning",
          "peer_score": 87.5,
          "instructor_grade": 85.0
        }
      ],
      "final_grade": 85.0
    }
  ]
}

Course All Reviews

Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary.

Route:

GET /courses/:id/course_report/all_reviews

Key computations:

  • Teammate scoresprecompute_teammate_scores bulk-loads TeammateReviewResponseMap records and returns { participant_id => "avg%" }.
  • Teammate countprecompute_teammate_counts counts distinct teammates per user across all assignments in the course.

Sample Response:

{
  "course_id": 1,
  "course_name": "CSC 517",
  "assignments": [{ "id": 10, "name": "Project 1" }],
  "rows": [
    {
      "user_id": 5,
      "user_name": "alice",
      "teammate_count": 2,
      "assignments": [
        { "assignment_id": 10, "assignment_name": "Project 1", "teammate_review": "83%" }
      ],
      "aggregate": "83%"
    }
  ]
}

ReviewGrade Model

Stores an instructor's grade and comment for a reviewer participant. Created alongside the Review Report frontend to support the grade/comment save workflow.

Migrations:

# 20260810135129_create_review_grades.rb
create_table :review_grades, if_not_exists: true do |t|
  t.integer  :participant_id, null: false, index: { unique: true }
  t.float    :grade_for_reviewer
  t.text     :comment_for_reviewer
  t.integer  :grader_id
  t.timestamps
end
# 20260813000001_add_instructor_grade_scores_to_assignments.rb
add_column :assignments, :instructor_grade_min_score, :integer
add_column :assignments, :instructor_grade_max_score, :integer

These two columns back the instructor_grade_min_score and instructor_grade_max_score fields exposed in assignment_params. The frontend's GradeCommentCell uses them to determine the valid grade range and warn instructors when a saved grade would fall outside it.

Endpoint:

PATCH /review_reports/:id/update_grade
# params: { grade_for_reviewer:, comment_for_reviewer: }
# :id is the participant_id (AssignmentParticipant)

Known limitation: ReviewGrade 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.

Frontend Architecture and Renders

The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.

Assignments

Few changes have been done to the assignment pages to include the reports and instructor grading scales.

ReviewReportPage

File: src/pages/ReviewReportPage/ReviewReportPage.tsx

  • Rounds 1–5 visibility toggled per reviewer row
  • MetricsChart — per-round averages with a Total bar; rounds where a reviewer did not participate are suppressed from the chart entirely
  • Status color coding per reviewer, including brown pre-population on reload from reviewer_grades in the fetch response
  • Scores awarded and average score display
  • Grade/comment save via PATCH /review_reports/:id/update_grade
  • CSV export, column sorting, search filter
  • Summary modal per reviewer
  • Color cutoffs at 90/80/70/60 (A/B/C/D scale) via getColorClass
  • Dynamic color bands via scoreToColor()k = min(maxScore, 10) HSL bands, green → red spectrum
  • Coloring relative to observed data range (dataMin/dataMax per round), not absolute rubric max
  • Review metrics column narrowed; barGap=1; slotWidth=75px between groups

TeammateReviewReportPage

File: src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx

  • Team grouping column
  • View modal showing scores per reviewee
  • Assignment name heading
  • Compact layout: p-3 container, line-height: 1.4, padding 6px 24px 6px 8px on th/td
  • tableStyle=Template:Width: "fit-content" — table shrinks to content, no full-width stretch
  • "Reviews" header on the view-link column
  • CSS scoped under .teammate-review-report-page (does not affect Review Report)
  • Status color #dc3545 for below-threshold scores

Courses

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.


Course Report Pages

Accessible from the course list: expand a course → assignment row → "View Review Report" button.

Page Frontend Route Backend Endpoint
CourseGradeSummaryPage courses/:courseId/course-report/grade-summary GET /courses/:id/course_report/grade_summary
CourseAllReviewsPage courses/:courseId/course-report/all-reviews GET /courses/:id/course_report/all_reviews

Both pages render a heat-grid layout where each cell's color is determined by the value's position in the observed data range (not a fixed rubric max).

Shared Utilities & Heat Grid

src/utils/heatgridUtils.ts

  • getHeatColorClass(value, dataMin, dataMax) — relative coloring, maps value to a CSS class based on its position between dataMin and dataMax.
  • getColorClass(score) — absolute grade-scale coloring (90/80/70/60 cutoffs).

src/utils/reviewTypes.ts

  • Exports ReviewData and SectionHeaderData interfaces. Re-exported from ViewTeamGrades/App.tsx.

Heat grid CSS (in custom.scss, global, after Bootstrap import, with !important):

.c5 { background: #1a7a4a; color: #fff; }  /* A — high */
.c4 { background: #5cb85c; }               /* B */
.c3 { background: #f0ad4e; }               /* C */
.c2 { background: #d9534f; color: #fff; }  /* D */
.c1 { background: #8b1a1a; color: #fff; }  /* F — low */
.c0 { background: #e0e0e0; }               /* no data */
.cf { background: #f8f8f8; }               /* empty */


Frontend Routes (App.tsx)

courses/:courseId/course-report/grade-summary  →  CourseGradeSummaryPage
courses/:courseId/course-report/all-reviews    →  CourseAllReviewsPage

Dropdown on the Review Report page: Review Report | Teammate Review Report. (Per Team report removed — no counterpart in Repo X.)

Authorization Changes

Controller Change
AssignmentsController Added action_allowed? → current_user_has_instructor_privileges?. Previously defaulted to true — any user could CRUD assignments.
CoursesController#index Scoped to Course.where(instructor_id: current_user.id). Admins still see Course.all.
AssignmentsController#index Scoped to instructor's own assignments plus assignments under their courses. Admins see Assignment.all.
ReportsController action_allowed? now checks current_user_has_admin_privileges? current_user_teaching_staff_of_assignment?. Removed overly broad instructor_privileges?.
CourseReportsController New controller; auth: current_user_has_instructor_privileges?.

RSpec Test Coverage

Spec file Covers
spec/requests/api/v1/review_reports_controller_spec.rb Response report fetch, update_grade endpoint
spec/requests/api/v1/review_grade_conflicts_spec.rb ReviewGrade conflict detection endpoint
spec/requests/api/v1/course_reports_controller_spec.rb grade_summary (incl. has_topics flag, weighted peer scores, penalty integration), all_reviews
spec/requests/api/v1/teammate_review_report_spec.rb Teammate review report endpoint
spec/models/response_map_spec.rb Added .compute_average_reviewer_score describe block (8 cases, uses instance_double)
spec/requests/api/v1/assignment_controller_spec.rb show/update with AQ data, instructor_grade_min/max fields

Pending Roadmap

Items identified in the 2026-08-19 team meeting, not yet implemented:

  • Info button on the heat grid explaining relative coloring (displayed max ≠ 100)
  • Toggle to show/hide the metrics/chart column
  • "Volume" column header label derived from active metrics (not hardwired)
  • Move rubric scale indicator (e.g. /5, −2 to 2) to column header — not repeated per cell
  • Partial review display: exclude incomplete rounds from averages; use purple; show timestamp / em-dash per round
  • Grade change warning banner when instructor lowers max points below an already-assigned score

Previously completed from the same meeting:

  • Color cutoffs changed to 90/80/70/60 (A/B/C/D scale)
  • Dynamic color bands via scoreToColor()
  • "Question" column header renamed to "Item" in heatgrid and feedback table
  • /100 hardcoded scale label removed from GradeCommentCell
  • Avg bar suppressed for rounds where reviewer did not participate

File Structure

app/
  controllers/
    reports_controller.rb               Entry point, REPORT_CLASSES dispatch
    course_reports_controller.rb        Grade summary & all-reviews endpoints  [NEW]
    assignments_controller.rb           show/update with render body: fix
  helpers/
    report_formatter_helper.rb          Empty namespace (logic moved to services)
    penalty_helper.rb                   get_penalty with nil-guard early return
  services/
    reports/
      base_report.rb                    Abstract pipeline template
      review_report.rb                  3-pipeline coordinator
      feedback_report.rb                Single pipeline, round bucketing
      teammate_review_report.rb         Single pipeline
      bookmark_rating_report.rb         Single pipeline
      basic_report.rb                   Simple struct
  models/
    review_grade.rb                     Grade & comment per reviewer  [NEW]
    response.rb                         maximum_score nil guard
    review_response_map.rb
    feedback_response_map.rb
    teammate_review_response_map.rb
    bookmark_rating_response_map.rb

db/
  migrate/
    20260810135129_create_review_grades.rb                      [NEW]
    20260813000001_add_instructor_grade_scores_to_assignments.rb  [NEW]

spec/
  requests/api/v1/
    review_reports_controller_spec.rb   [NEW]
    review_grade_conflicts_spec.rb      [NEW]
    course_reports_controller_spec.rb   [NEW]
    teammate_review_report_spec.rb      [NEW]
    assignment_controller_spec.rb       Updated
  models/
    response_map_spec.rb                compute_average_reviewer_score block  [NEW]

# Frontend (reimplementation-front-end repo)
src/
  pages/
    ReviewReportPage/ReviewReportPage.tsx         [NEW]
    TeammateReviewReportPage/TeammateReviewReportPage.tsx  [NEW]
    CourseGradeSummaryPage/CourseGradeSummaryPage.tsx      [NEW]
    CourseAllReviewsPage/CourseAllReviewsPage.tsx           [NEW]
  utils/
    heatgridUtils.ts                    getColorClass, getHeatColorClass  [NEW]
    reviewTypes.ts                      ReviewData, SectionHeaderData  [NEW]
  styles/
    custom.scss                         .c0–.c5, .cf heat-grid classes

Blocked Report Types

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.

Report Type Missing Dependency Repo X Location
calibration calibrate_to column on response_maps report_formatter_helper.rb
self_review SelfReviewResponseMap model self_review_response_map.rb
survey survey_deployments table survey_response_map.rb
quiz quiz_responses table quiz_response_map.rb
answer_tagging tag_prompt_deployments, answer_tags tables tag_prompt_deployment.rb

To add a blocked report once its dependencies are available:

  1. Create app/services/reports/<name>_report.rb inheriting BaseReport.
  2. Define source, grouper, initial_state, accumulate, and finalize.
  3. Add an entry to ReportsController::REPORT_CLASSES.

Comparison with Repo X

Concern Repo X Repo Y
Output format ERB instance variables (@reviewers, @review_scores) JSON hash from report.run
Loading strategy All records loaded into arrays at once find_each batched streaming
Metrics location compute_metrics in helper base Each report owns accumulate and finalize
Dispatch send(@type.underscore, params, session) REPORT_CLASSES[type].new(assignment).run
N+1 on scores response.maximum_score per row — questionnaire lookup each time Precomputed round→max_score map, one query before pipeline
Deduplication Array#include? — O(n) per check Set#include? — O(1) per check
Course reports Grade book & review summary in ERB views CourseReportsController with JSON API endpoints
Reviewer grading ReviewGrade on Assignment model Dedicated ReviewGrade model, update_grade endpoint
Additional features LLM evaluation, CSV export, calibration, self-review, survey, quiz, answer tagging CSV export ✓; others blocked on schema

Author

Name Role
Aanand Sreekumaran Nair Jayakumari Project contributor / developer — Report Generation Framework
Bestin Lalu Project contributor / developer — Course Reports, ReviewGrade model, Frontend