Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

### ✨ New features and improvements
- Enable test results downloads through the API (#7754)
- Provide suggestions for partial student matching scans (#7760)

### 🐛 Bug fixes

Expand Down
42 changes: 42 additions & 0 deletions app/assets/stylesheets/common/_ocr_suggestions.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// OCR Suggestions Styles
// Used in assign_scans view for displaying OCR match data and student suggestions

@import 'constants';

.ocr-suggestions-container {
margin: 1em 0;
padding: 1em;
background-color: $background-support;
border: 1px solid $gridline;
border-radius: var(--radius);
max-height: 400px;
overflow-y: auto;
overflow-x: hidden;

code {
background-color: $disabled-area;
padding: 0.2em 0.4em;
border-radius: 3px;
}

.no-match {
color: $disabled-text;
font-style: italic;
}
}

.ocr-suggestions-list {
margin-top: 0.5em;
position: static;

.ui-menu-item div:hover {
background-color: $primary-three;
color: $sharp-line;
}

.student-info {
font-size: 1.1em;
color: $sharp-line;
font-weight: 500;
}
}
1 change: 1 addition & 0 deletions app/assets/stylesheets/common/core.scss
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
@import 'markus';
@import 'constants';
@import 'navigation';
@import 'ocr_suggestions';
@import '../../../../node_modules/@fortawesome/fontawesome-svg-core/styles';

#about_dialog {
Expand Down
35 changes: 32 additions & 3 deletions app/controllers/groups_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -147,12 +147,18 @@ def assign_scans
if num_valid == num_total
flash_message(:success, t('exam_templates.assign_scans.done'))
end
# Get OCR match data and suggestions if available
ocr_match = OcrMatchService.get_match(next_grouping.id)
ocr_suggestions = ocr_match ? OcrMatchService.get_suggestions(next_grouping.id, current_course.id) : []

@data = {
group_name: next_grouping.group.group_name,
grouping_id: next_grouping.id,
students: names,
num_total: num_total,
num_valid: num_valid
num_valid: num_valid,
ocr_match: ocr_match,
ocr_suggestions: format_ocr_suggestions(ocr_suggestions)
}
next_file = next_grouping.current_submission_used.submission_files.find_by(filename: 'COVER.pdf')
if next_file.nil?
Expand Down Expand Up @@ -221,6 +227,8 @@ def assign_student_and_next
end
StudentMembership
.find_or_create_by(role: student, grouping: @grouping, membership_status: StudentMembership::STATUSES[:inviter])
# Clear OCR match data after successful assignment
OcrMatchService.clear_match(@grouping.id)
end
next_grouping
end
Expand All @@ -243,20 +251,28 @@ def next_grouping
if num_valid == num_total
flash_message(:success, t('exam_templates.assign_scans.done'))
end
# Get OCR match data and suggestions if available
ocr_match = OcrMatchService.get_match(next_grouping.id)
ocr_suggestions = ocr_match ? OcrMatchService.get_suggestions(next_grouping.id, current_course.id) : []

if !@grouping.nil? && next_grouping.id == @grouping.id
render json: {
grouping_id: next_grouping.id,
students: names,
num_total: num_total,
num_valid: num_valid
num_valid: num_valid,
ocr_match: ocr_match,
ocr_suggestions: format_ocr_suggestions(ocr_suggestions)
}
else
data = {
group_name: next_grouping.group.group_name,
grouping_id: next_grouping.id,
students: names,
num_total: num_total,
num_valid: num_valid
num_valid: num_valid,
ocr_match: ocr_match,
ocr_suggestions: format_ocr_suggestions(ocr_suggestions)
}
next_file = next_grouping.current_submission_used.submission_files.find_by(filename: 'COVER.pdf')
unless next_file.nil?
Expand Down Expand Up @@ -728,6 +744,19 @@ def remove_member(membership, grouping)
grouping.reload
end

# Format OCR suggestions for JSON response
def format_ocr_suggestions(ocr_suggestions)
ocr_suggestions.map do |s|
{
id: s[:student].id,
user_name: s[:student].user.user_name,
id_number: s[:student].user.id_number,
display_name: s[:student].user.display_name,
similarity: (s[:similarity] * 100).round(1)
}
end
end

# This override is necessary because this controller is acting as a controller
# for both groups and groupings.
#
Expand Down
2 changes: 2 additions & 0 deletions app/javascript/application_webpack.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ import {refreshOrLogout} from "./common/refresh_or_logout";
window.refreshOrLogout = refreshOrLogout;
import {ModalMarkus} from "./common/modals";
window.ModalMarkus = ModalMarkus;
import {updateOcrSuggestions} from "./common/ocr_suggestions";
window.updateOcrSuggestions = updateOcrSuggestions;
import {makeDashboard} from "./Components/dashboard";
window.makeDashboard = makeDashboard;
import {makeAssignmentSummary} from "./Components/assignment_summary";
Expand Down
70 changes: 70 additions & 0 deletions app/javascript/common/ocr_suggestions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* OCR Suggestions Module
* Handles display and interaction with OCR match data and student suggestions
* Used in the assign_scans view for exam template processing
*/

export function updateOcrSuggestions(ocrMatch, suggestions = []) {
const container = $("#ocr_suggestions");
container.empty();

if (!ocrMatch) {
container.hide();
return;
}

container.show();

// internationalization
const noId = I18n.t("exam_templates.assign_scans.no_id");
const idNumber = I18n.t("activerecord.attributes.user.id_number");
const userName = I18n.t("activerecord.attributes.user.user_name");
const suggestedStudents = I18n.t("exam_templates.assign_scans.suggested_students");
const noSimilarStudents = I18n.t("exam_templates.assign_scans.no_similar_students");

const ocrDisplay = $("<p></p>");
// Display the parsed OCR value
const parsedValue = ocrMatch.parsed_value;
const fieldType = ocrMatch.field_type === "id_number" ? idNumber : userName;
const ocrDetected = I18n.t("exam_templates.assign_scans.ocr_detected", {field_type: fieldType});

ocrDisplay.append(`<strong>${ocrDetected}</strong>`);
const codeElem = $("<code></code>").text(parsedValue);
ocrDisplay.append(codeElem);
container.append(ocrDisplay);

if (suggestions.length == 0) {
return container.append(`<p class="no-match">${noSimilarStudents}</p>`);
}

// Display suggestions if available
container.append(`<p><strong>${suggestedStudents}</strong></p>`);
const list = $('<ul class="ui-menu ocr-suggestions-list"></ul>');

suggestions.forEach(function (suggestion) {
const similarity = suggestion.similarity;
const item = $('<li class="ui-menu-item"></li>');
const content = $("<div></div>");

// Use .text() to safely insert user-supplied data and prevent XSS
const nameElem = $("<strong></strong>").text(suggestion.display_name);
const infoText = `${suggestion.id_number || noId} | ${suggestion.user_name}`;
const infoElem = $('<span class="student-info"></span>').text(infoText);

content.append(nameElem);
content.append(` (${similarity}%)`);
content.append("<br>");
content.append(infoElem);

content.on("click", function () {
$("#student_id").val(suggestion.id);
$("#names").val(suggestion.display_name);
$("#names").focus();
});

item.append(content);
list.append(item);
});

container.append(list);
}
12 changes: 11 additions & 1 deletion app/jobs/auto_match_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ def perform(groupings, exam_template)
next unless status.success? && parsed.length == 1

student = match_student(parsed[0], exam_template)

# Store OCR match result in Redis for later suggestions
OcrMatchService.store_match(
grouping.id,
parsed[0],
exam_template.cover_fields,
matched: !student.nil?,
student_id: student&.id
)

unless student.nil?
StudentMembership.find_or_create_by(role: student,
grouping: grouping,
Expand All @@ -67,7 +77,7 @@ def perform(groupings, exam_template)
def match_student(parsed, exam_template)
case exam_template.cover_fields
when 'id_number'
Student.joins(:user).find_by('user.id_number': parsed)
Student.joins(:user).find_by('users.id_number': parsed)
when 'user_name'
Student.joins(:user).find_by(User.arel_table[:user_name].matches(parsed))
end
Expand Down
113 changes: 113 additions & 0 deletions app/lib/ocr_match_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Service for storing and retrieving OCR match data from Redis.
# Used to persist OCR parsing results for scanned exam assignments,
# enabling suggestions for manual student assignment.
class OcrMatchService
# Time-to-live for OCR match data in Redis (30 days)
TTL = 30.days.to_i

class << self
# Store an OCR match result in Redis
def store_match(grouping_id, parsed_value, field_type, matched: false, student_id: nil)
data = {
parsed_value: parsed_value,
field_type: field_type,
timestamp: Time.current.iso8601,
matched: matched,
matched_student_id: student_id
}

redis.setex(match_key(grouping_id), TTL, data.to_json)

# Add to unmatched set if not auto-matched
unless matched
redis.sadd(unmatched_set_key, grouping_id)
redis.expire(unmatched_set_key, TTL)
end
end

# Retrieve stored OCR match data for a grouping
def get_match(grouping_id)
data = redis.get(match_key(grouping_id))
data ? JSON.parse(data, symbolize_names: true) : nil
end

# Get student suggestions based on stored OCR match using fuzzy matching
# Only considers students not already assigned to a grouping for this assignment
# Returns students meeting the similarity threshold (default 80%), limited to top matches (default 5)
def get_suggestions(grouping_id, course_id, threshold: 0.8, limit: 5)
match_data = get_match(grouping_id)
return [] if match_data.nil?

grouping = Grouping.find(grouping_id)
assignment = grouping.assignment
course = Course.find(course_id)

# Get students who are not assigned to any grouping for this assignment
assigned_student_ids = assignment.groupings
.joins(:student_memberships)
.pluck('memberships.role_id')
students = course.students.includes(:user).where.not(id: assigned_student_ids)

# Calculate similarity scores for each student
suggestions = students.filter_map do |student|
value_to_match = student_match_value(student, match_data[:field_type])
next if value_to_match.blank?

similarity = string_similarity(match_data[:parsed_value], value_to_match)
next if similarity < threshold

{ student: student, similarity: similarity }
end

# Sort by similarity (highest first) and limit results
suggestions.sort_by { |s| -s[:similarity] }.take(limit)
end

# Clear OCR match data after manual assignment
def clear_match(grouping_id)
redis.del(match_key(grouping_id))
redis.srem(unmatched_set_key, grouping_id)
end

private

def match_key(grouping_id)
"ocr_matches:grouping:#{grouping_id}"
end

def unmatched_set_key
'ocr_matches:unmatched'
end

def redis
Redis::Namespace.new(Rails.root.to_s, redis: Resque.redis)
end

# Get the value to match against based on field type
def student_match_value(student, field_type)
case field_type
when 'id_number' then student.user.id_number
when 'user_name' then student.user.user_name
end
end

# Calculate similarity between two strings using Levenshtein distance
# Returns a score between 0 and 1, where 1 is identical
def string_similarity(str1, str2)
return 1.0 if str1 == str2
return 0.0 if str1.blank? || str2.blank?

# Normalize strings for case-insensitive comparison
s1 = str1.to_s.downcase.strip
s2 = str2.to_s.downcase.strip
return 1.0 if s1 == s2

# Use Ruby's built-in Levenshtein distance calculation
distance = DidYouMean::Levenshtein.distance(s1, s2)
max_length = [s1.length, s2.length].max
return 0.0 if max_length.zero?

1.0 - (distance.to_f / max_length)
end
end
end
10 changes: 10 additions & 0 deletions app/views/groups/assign_scans.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@
$("#assign_student").find("#skip").val("1").prop('checked', false);
let current_group = $("#grouping_id").val();
update_bar(data.num_valid, data.num_total);

// Update OCR suggestions
updateOcrSuggestions(data.ocr_match, data.ocr_suggestions);

// Anytime we advance to another assignment
if (data.grouping_id !== current_group) {
$("#grouping_id").val(data.grouping_id);
Expand Down Expand Up @@ -134,6 +138,12 @@
<button type="submit"><%= t('save') %></button>
</p>
</form>

<!-- OCR Suggestions Section -->
<div id="ocr_suggestions" class="ocr-suggestions-container" style="display: none;">
<!-- Will be populated by JavaScript -->
</div>

<h3>
<%= Group.human_attribute_name(:student_memberships) %>
</h3>
Expand Down
4 changes: 4 additions & 0 deletions config/locales/views/exam_templates/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,13 @@ en:
done: All groups have been successfully assigned students
help: Assign students to scanned exam groups based on printed student names.
no_cover_page: This submission does not have a cover page.
no_id: No ID
no_similar_students: No similar students found. Please assign manually.
not_all_submissions_collected: Not all submissions have been collected.
ocr_detected: 'OCR Detected %{field_type}:'
skip_group: Skip group
student_not_found: Student with name %{name} does not exist.
suggested_students: 'Suggested Students:'
title: Assign Scans
back_to_exam_templates_page: Back to Exam Templates page
create:
Expand Down
Loading