Reports and Course Summary
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 |