Reports and Course Summary: Difference between revisions
(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...") |
No edit summary |
||
| Line 1: | Line 1: | ||
= Report Generation = | = Report Generation Framework = | ||
== 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: | |||
< | <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) | ||
</ | </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 | 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 | |||
=== 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: | ||
< | <pre> | ||
# WRONG — in BaseReport | # WRONG — in BaseReport | ||
def compute_metrics(grouped) | def compute_metrics(grouped) | ||
| Line 66: | Line 53: | ||
end | end | ||
end | end | ||
</ | </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''' | |||
==== What "Domain Math" Means ==== | ==== What "Domain Math" Means ==== | ||
Domain math refers to the business-logic calculations specific to a particular report type. | |||
particular report type | |||
{| 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 | |||
|- | |- | ||
| 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 | |||
|} | |} | ||
'''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. | |||
== 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>: | |||
< | <pre> | ||
def run | def run | ||
state = initial_state | state = initial_state | ||
| Line 241: | Line 89: | ||
finalize(state) | finalize(state) | ||
end | end | ||
</ | </pre> | ||
The pipeline consists of four concerns: | The pipeline consists of four concerns: | ||
| Line 248: | Line 96: | ||
! Concern !! Responsibility | ! Concern !! Responsibility | ||
|- | |- | ||
| | | 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 || 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 || 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 || 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 | 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 === | ||
< | <pre> | ||
module Reports | module Reports | ||
class BaseReport | class BaseReport | ||
| Line 299: | Line 141: | ||
end | end | ||
end | end | ||
</ | </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. | |||
< | <pre> | ||
REPORT_CLASSES = { | REPORT_CLASSES = { | ||
'review_response_map' => Reports::ReviewReport, | 'review_response_map' => Reports::ReviewReport, | ||
| 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 | ||
</ | </pre> | ||
=== | === Routes === | ||
< | <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 | ||
</ | 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 | ||
|- | |- | ||
| | | ReviewersPipeline || ReviewResponseMap || reviewer_id || Sorted reviewer list | ||
| | |||
| | |||
| Sorted reviewer list | |||
|- | |- | ||
| | | ScoresPipeline || Response JOIN map || reviewer_id || Score percentage per round/reviewee | ||
| | |||
| | |||
| Score percentage per round/reviewee | |||
|- | |- | ||
| | | AvgRangesPipeline || Response JOIN map || [reviewee_id, round] || Max/min/avg per team/round | ||
| | |||
| | |||
| 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: | ||
In Repo Y, both score pipelines precompute a | |||
<code>round -> max_question_score</code> map with a single query before the | |||
pipeline runs: | |||
< | <pre> | ||
def precompute_max_q_scores | def precompute_max_q_scores | ||
AssignmentQuestionnaire | AssignmentQuestionnaire | ||
| Line 396: | Line 209: | ||
.to_h | .to_h | ||
end | end | ||
# Used inside accumulate: | |||
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) | ||
</ | </pre> | ||
==== Sample Response ==== | ==== Sample Response ==== | ||
< | <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 } } } | ||
} | } | ||
</ | </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 | |||
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. | |||
==== Sample Response (varying rubrics) ==== | ==== Sample Response (varying rubrics) ==== | ||
< | <pre> | ||
{ | { | ||
"type": "feedback_response_map", | "type": "feedback_response_map", | ||
| Line 541: | Line 245: | ||
} | } | ||
} | } | ||
</ | </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>. | |||
See the [[#TeammateReviewReportPage|TeammateReviewReportPage]] section for the corresponding frontend. | |||
==== Sample Response ==== | ==== Sample Response ==== | ||
< | <pre> | ||
{ | { | ||
"type": "teammate_review_response_map", | "type": "teammate_review_response_map", | ||
| Line 563: | Line 263: | ||
] | ] | ||
} | } | ||
</ | </pre> | ||
=== Bookmark Rating Report (<code>bookmark_rating_response_map</code>) === | === Bookmark Rating Report (<code>bookmark_rating_response_map</code>) === | ||
Streams <code>BookmarkRatingResponseMap</code> records, accumulating distinct | Streams <code>BookmarkRatingResponseMap</code> records, accumulating distinct bookmark IDs into a Set. Project topics are fetched once in <code>finalize</code>. | ||
bookmark IDs into a | |||
Project topics are fetched once in <code>finalize</code>. | |||
==== Bug Fixed During Port ==== | ==== Bug Fixed During Port ==== | ||
The model's <code>bookmark_response_report</code> in Repo Y was incorrectly | The model's <code>bookmark_response_report</code> in Repo Y was incorrectly calling <code>.pluck(:reviewed_object_id)</code>, which returns assignment IDs. Bookmark IDs are stored in <code>reviewee_id</code>. Fixed to <code>.pluck(:reviewee_id)</code>. | ||
calling | |||
< | |||
.pluck(:reviewed_object_id) | |||
</ | |||
Bookmark IDs are stored in <code>reviewee_id</code>. | |||
to | |||
< | |||
.pluck(:reviewee_id) | |||
</ | |||
==== Sample Response ==== | ==== Sample Response ==== | ||
< | <pre> | ||
{ | { | ||
"type": "bookmark_rating_response_map", | "type": "bookmark_rating_response_map", | ||
| Line 601: | Line 281: | ||
"topics": [{ "id": 3, "topic_name": "Machine Learning" }] | "topics": [{ "id": 3, "topic_name": "Machine Learning" }] | ||
} | } | ||
</ | </pre> | ||
=== Basic Report (<code>basic</code>) === | === Basic Report (<code>basic</code>) === | ||
Returns minimal assignment metadata. | 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. | ||
No streaming is required since all data comes from the already-loaded | |||
==== Sample Response ==== | ==== Sample Response ==== | ||
< | <pre> | ||
{ | { | ||
"type": "basic", | "type": "basic", | ||
| Line 626: | Line 299: | ||
} | } | ||
} | } | ||
</ | </pre> | ||
=== Course Grade Summary === | |||
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>. | |||
'''Route:''' | |||
<pre> | |||
GET /courses/:id/course_report/grade_summary | |||
</pre> | |||
'''Key computations:''' | |||
* '''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. | |||
* '''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. | |||
* '''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. | |||
<pre> | |||
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 | |||
</pre> | |||
'''Sample Response:''' | |||
<pre> | |||
{ | |||
"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 | |||
} | |||
] | |||
} | |||
</pre> | |||
=== Course All Reviews === | |||
Aggregates teammate review scores received per student per assignment within a course. Companion endpoint to grade summary. | |||
'''Route:''' | |||
<pre> | |||
GET /courses/:id/course_report/all_reviews | |||
</pre> | |||
'''Key computations:''' | |||
* '''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:''' | |||
<pre> | |||
{ | |||
"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%" | |||
} | |||
] | |||
} | |||
</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 == | |||
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges. | |||
=== 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 | |||
=== 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 | |||
=== 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). | |||
=== 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 == | ||
< | <pre> | ||
app/ | app/ | ||
controllers/ | controllers/ | ||
reports_controller.rb | 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 | 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 | base_report.rb Abstract pipeline template | ||
review_report.rb | review_report.rb 3-pipeline coordinator | ||
feedback_report.rb | feedback_report.rb Single pipeline, round bucketing | ||
teammate_review_report.rb | teammate_review_report.rb Single pipeline | ||
bookmark_rating_report.rb | bookmark_rating_report.rb Single pipeline | ||
basic_report.rb | 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 | ||
feedback_response_map.rb | feedback_response_map.rb | ||
teammate_review_response_map.rb | teammate_review_response_map.rb | ||
bookmark_rating_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 | |||
</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 | ||
|- | |- | ||
| | | calibration || <code>calibrate_to</code> column on response_maps || report_formatter_helper.rb | ||
| <code>calibrate_to</code> column on | |||
| | |||
|- | |- | ||
| | | self_review || <code>SelfReviewResponseMap</code> model || self_review_response_map.rb | ||
| <code>SelfReviewResponseMap</code> model | |||
| | |||
|- | |- | ||
| | | survey || <code>survey_deployments</code> table || survey_response_map.rb | ||
| <code>survey_deployments</code> table | |||
| | |||
|- | |- | ||
| | | quiz || <code>quiz_responses</code> table || quiz_response_map.rb | ||
| <code>quiz_responses</code> table | |||
| | |||
|- | |- | ||
| | | 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 | |||
| | |||
|} | |} | ||
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/ | # 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 661: | ||
! 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 | |||
| 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> | |||
|- | |- | ||
| 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 | |- | ||
| | | 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 685: | ||
! Name !! Role | ! Name !! Role | ||
|- | |- | ||
| | | Aanand Sreekumaran Nair Jayakumari || Project contributor / developer — Report Generation Framework | ||
| Project contributor / developer | |- | ||
| Bestin Lalu || Project contributor / developer — Course Reports, ReviewGrade model, Frontend | |||
|} | |} | ||
Revision as of 14:42, 26 August 2026
Report Generation Framework
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,
'teammate_review_response_map' => Reports::TeammateReviewReport,
'bookmark_rating_response_map' => Reports::BookmarkRatingReport,
'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)
Streams BookmarkRatingResponseMap records, accumulating distinct bookmark IDs into a Set. Project topics are fetched once in finalize.
Bug Fixed During Port
The model's bookmark_response_report in Repo Y was incorrectly calling .pluck(:reviewed_object_id), which returns assignment IDs. Bookmark IDs are stored in reviewee_id. Fixed to .pluck(:reviewee_id).
Sample Response
{
"type": "bookmark_rating_response_map",
"bookmark_ids": [10, 14, 22],
"topics": [{ "id": 3, "topic_name": "Machine Learning" }]
}
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 score —
precompute_peer_scoresbulk-loads allReviewResponseMaprecords for the course in one query, computing weighted average percentage scores per (assignment, team) pair. Reviewer weight comes fromReviewGrade#grade_for_reviewer; defaults to 1.0 if absent. - Instructor grade —
team.grade_for_submissionminus the late penalty fromPenaltyHelper#get_penalty(participant_id)[:submission]. Returnsnilwhen no grade has been set. - Penalty guard —
get_penaltyreturns{ submission: 0, review: 0, meta_review: 0 }immediately when the assignment has no late policy (@penalty_per_unitis nil), avoiding constant-lookup errors forMetareviewResponseMap. - Calibrated assignments excluded — assignments where
is_calibrated: trueare 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 scores —
precompute_teammate_scoresbulk-loadsTeammateReviewResponseMaprecords and returns{ participant_id => "avg%" }. - Teammate count —
precompute_teammate_countscounts 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
The following React/TypeScript pages implement the instructor-facing report views. All pages are gated behind instructor privileges.
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_gradesin 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=75pxbetween groups
TeammateReviewReportPage
File: src/pages/TeammateReviewReportPage/TeammateReviewReportPage.tsx
- Team grouping column
- View modal showing scores per reviewee
- Assignment name heading
- Compact layout:
p-3container,line-height: 1.4, padding6px 24px 6px 8pxon 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
#dc3545for below-threshold scores
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).
src/utils/heatgridUtils.ts
getHeatColorClass(value, dataMin, dataMax)— relative coloring, maps value to a CSS class based on its position betweendataMinanddataMax.getColorClass(score)— absolute grade-scale coloring (90/80/70/60 cutoffs).
src/utils/reviewTypes.ts
- Exports
ReviewDataandSectionHeaderDatainterfaces. Re-exported fromViewTeamGrades/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
/100hardcoded 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:
- Create
app/services/reports/<name>_report.rbinheritingBaseReport. - Define
source,grouper,initial_state,accumulate, andfinalize. - 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 |