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...") |
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: | ||
= | = 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: | |||
< | <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, | ||
'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 | ||
</ | </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: | ||
<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>) === | |||
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. | ||
=== | ==== 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 === | |||
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 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 == | ||
< | <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 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 | |||
| 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 708: | ||
! 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 | |||
|} | |} | ||
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 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 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.
-
This is the Assignments tab for an instructor/admin.
-
Review the strategy tab for an assignment where min and max scores for the reviews can be set.
-
The implemented reports have been listed in the View Reports button.
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
-
The Review Report page showing reviewer scores and reviewer grade for an assignment with a collapsible legend.
-
The Review Report page (with sticky headers) showing reviewer scores and visual metrics comparing the reviewer and average.
-
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.
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
-
The Teammate Review Report page showing the status of reviews required by teammates about each other.
-
Detailed teammate reviews page redirected from the previous Teammate Review Report page view button
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.
-
This is the courses tab accessed by instructors/admin.
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).
-
A horizontal scroll and heatstyle enabled table which shows detailed grade distribution for each students in the course per assignment.
-
A horizontal scroll and heatstyle enabled table which shows detailed teammate reviews given and received by each teammate per assignment.
-
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.
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 |










