Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 3 additions & 1 deletion app/controllers/course/assessment/question/controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ def load_question_assessment_for(question)
end

def update_skill_ids_if_params_present(question_assessment_params)
skill_ids_params = question_assessment_params[:skill_ids] unless question_assessment_params[:skill_ids].nil?
return if question_assessment_params.nil? || question_assessment_params[:skill_ids].nil?

skill_ids_params = question_assessment_params[:skill_ids]
@question_assessment.skill_ids = skill_ids_params unless skill_ids_params.nil?
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,15 @@ def text_response_question_params
)
else
permitted_params.concat(
[solutions_attributes: [:_destroy, :id, :solution_type, :solution, :grade, :explanation]]
[solutions_attributes: [
:_destroy, :id, :solution_type, :solution, :grade, :explanation,
test_spreadsheet_attributes: [
:id, :file, :_destroy, :is_randomization_enabled, :num_random_tests,
:is_random_seed_fixed, :test_random_seed,
:is_timestamp_fixed, :test_timestamp,
:variables
Comment thread
adi-herwana-nus marked this conversation as resolved.
]
]]
)
end
params.require(:question_text_response).permit(*permitted_params)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,12 @@ def reevaluate_answer
return head :bad_request if @answer.nil?

job = @answer.auto_grade!(redirect_to_path: nil, reduce_priority: true)
render partial: 'jobs/submitted', locals: { job: job.job }
if job.nil?
@answer.reload
render @answer
else
render partial: 'jobs/submitted', locals: { job: job.job }
end
end

def generate_feedback
Expand Down
5 changes: 5 additions & 0 deletions app/models/course/assessment/answer/text_response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ def normalized_answer_text
answer_text.strip.encode(universal_newline: true)
end

# If text response grading requires formula evaluation, it should be graded in a job.
def grade_inline?
question.actable.solutions.all? { |solution| !solution.spreadsheet_formula? }
end

def download(dir)
download_answer(dir) unless question.actable.file_upload_question?
attachments.each { |a| download_attachment(a, dir) }
Expand Down
14 changes: 13 additions & 1 deletion app/models/course/assessment/question/text_response_solution.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# frozen_string_literal: true
class Course::Assessment::Question::TextResponseSolution < ApplicationRecord
enum :solution_type, [:exact_match, :keyword]
enum :solution_type, [:exact_match, :keyword, :regex, :spreadsheet_formula]

before_validation :strip_whitespace
before_save :sanitize_explanation
Expand All @@ -12,6 +12,18 @@ class Course::Assessment::Question::TextResponseSolution < ApplicationRecord

belongs_to :question, class_name: 'Course::Assessment::Question::TextResponse',
inverse_of: :solutions
has_one :test_spreadsheet, class_name: 'Course::Assessment::Question::TextResponseSolutionSpreadsheet',
inverse_of: :solution, dependent: :destroy, autosave: true
accepts_nested_attributes_for :test_spreadsheet, allow_destroy: true

def test_spreadsheet_attributes=(attributes)
if ActiveRecord::Type::Boolean.new.cast(attributes[:_destroy])
test_spreadsheet&.mark_for_destruction
else
spreadsheet = test_spreadsheet || build_test_spreadsheet
spreadsheet.assign_params(attributes)
end
end

def initialize_duplicate(duplicator, other)
self.question = duplicator.duplicate(other.question)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true
class Course::Assessment::Question::TextResponseSolutionSpreadsheet < ApplicationRecord
belongs_to :solution, class_name: 'Course::Assessment::Question::TextResponseSolution',
inverse_of: :test_spreadsheet

has_one_attachment

validates :attachment_reference, presence: true

def assign_params(params)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/AbcSize: Assignment Branch Condition size for assign_params is too high. [<8, 25, 8> 27.44/20]
Metrics/CyclomaticComplexity: Cyclomatic complexity for assign_params is too high. [9/7]
Metrics/PerceivedComplexity: Perceived complexity for assign_params is too high. [9/8]

self.file = params[:file] if params[:file]
self.is_randomization_enabled = params[:is_randomization_enabled] if params.key?(:is_randomization_enabled)
self.num_random_tests = params[:num_random_tests] if params.key?(:num_random_tests)
self.is_random_seed_fixed = params[:is_random_seed_fixed] if params.key?(:is_random_seed_fixed)
self.test_random_seed = params[:test_random_seed] if params.key?(:test_random_seed)
self.is_timestamp_fixed = params[:is_timestamp_fixed] if params.key?(:is_timestamp_fixed)
self.test_timestamp = params[:test_timestamp] if params.key?(:test_timestamp)
self.variables = JSON.parse(params[:variables]) if params[:variables]
end

def container_filename
ext = attachment&.name ? File.extname(attachment.name) : ''
"sheet_#{id}#{ext}"
end
Comment thread
adi-herwana-nus marked this conversation as resolved.
end
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,25 @@
class Course::Assessment::Answer::TextResponseAutoGradingService < \
Course::Assessment::Answer::AutoGradingService
def evaluate(answer)
answer.correct, grade, messages = evaluate_answer(answer.actable)
answer.auto_grading.result = { messages: messages }
answer.correct, grade, evaluation_results = evaluate_answer(answer.actable)
answer.auto_grading.result = {
messages: explanations_for(evaluation_results),
evaluation_results: evaluation_results.map do |result|
result_json = {
solution_id: result.solution.id,
grade: result.grade
}
result_json[:tests] = result.results if result.results
result_json
end
}
grade
end

private

SolutionEvaluationResult = Struct.new(:solution, :grade, :explanation, :results)

# Grades the given answer.
#
# @param [Course::Assessment::Answer::TextResponse] answer The answer specified by the
Expand All @@ -18,42 +30,158 @@ def evaluate(answer)
def evaluate_answer(answer)
question = answer.question.actable
answer_text = answer.normalized_answer_text
exact_matches, keywords = question.solutions.partition(&:exact_match?)
solutions_by_type = question.solutions.group_by(&:solution_type).symbolize_keys

solutions = find_exact_match(answer_text, exact_matches)
# If there is no exact match, we fall back to keyword matches.
exact_match_solution = find_correct_exact_match_solution(answer_text, solutions_by_type[:exact_match] || [])
# Solutions are always kept in an array for easier use of #grade_for and #explanations_for
solutions = solutions.present? ? [solutions] : find_keywords(answer_text, keywords)

evaluation_results =
if exact_match_solution
[
SolutionEvaluationResult.new(
exact_match_solution,
exact_match_solution.grade,
exact_match_solution.explanation
)
]
else
evaluate_correct_keyword_solutions(answer_text, solutions_by_type[:keyword] || []) +
evaluate_correct_regex_solutions(answer_text, solutions_by_type[:regex] || []) +
evaluate_spreadsheet_formula_solutions(answer_text, solutions_by_type[:spreadsheet_formula] || [])
end
[
correctness_for(question, solutions),
grade_for(question, solutions),
explanations_for(solutions)
correctness_for(question, evaluation_results),
grade_for(question, evaluation_results),
evaluation_results
]
end

# Returns one solution that exactly matches the answer.
#
# @param [String] answer_text The answer text entered by the student.
# @param [Array<Course::Assessment::Question::TextResponseSolution>] solutions The solutions
# @param [Array<Course::Assessment::Question::TextResponse::Solution>] solutions The solutions
# to be matched against answer_text.
# @return [Course::Assessment::Question::TextResponseSolution] Solution that exactly matches
# @return [Course::Assessment::Question::TextResponse::Solution] Solution that exactly matches
# the answer.
def find_exact_match(answer_text, solutions)
def find_correct_exact_match_solution(answer_text, solutions)
# comparison is case insensitive
solutions.find { |s| s.solution.encode(universal_newline: true).casecmp(answer_text) == 0 }
end

# Returns the keywords found in the given answer text.
#
# @param [String] answer_text The answer text entered by the student.
# @param [Array<Course::Assessment::Question::TextResponseSolution>] solutions The solutions
# @param [Array<Course::Assessment::Question::TextResponse::Solution>] solutions The solutions
# to be matched against answer_text.
# @return [Array<Course::Assessment::Question::TextResponseSolution>] Solutions that matches
# @return [Array<Course::Assessment::Question::TextResponse::Solution>] Solutions that matches
# the answer.
def find_keywords(answer_text, solutions)
def evaluate_correct_keyword_solutions(answer_text, solutions)
# TODO(minqi): Add tokenizer and stemmer for more natural keyword matching.
solutions.select { |s| answer_text.downcase.include?(s.solution.downcase) }
solutions.select { |s| answer_text.downcase.include?(s.solution.downcase) }.
map { |s| SolutionEvaluationResult.new(s, s.grade, s.explanation) }
end

# Returns the regexes that match the given answer text.
#
# @param [String] answer_text The answer text entered by the student.
# @param [Array<Course::Assessment::Question::TextResponse::Solution>] solutions The solutions
# to be matched against answer_text.
# @return [Array<Course::Assessment::Question::TextResponse::Solution>] Solutions that matches
# the answer.
def evaluate_correct_regex_solutions(answer_text, solutions)
solutions.map do |s|
match = answer_text.match?(Regexp.new(s.solution, Regexp::IGNORECASE, timeout: 15.0))
SolutionEvaluationResult.new(s, s.grade, s.explanation) if match
rescue Regexp::TimeoutError
SolutionEvaluationResult.new(
s, 0, I18n.t('errors.course.assessment.text_response_auto_grading.grade.regex_timeout')
)
end.compact
end
Comment on lines +90 to +99

def normalize_formula_text(text)
formula_text = text.strip
formula_text.start_with?('=') ? formula_text : "=#{formula_text}"
end

def container_test_num_random_tests(solution)
solution.is_randomization_enabled ? solution.test_spreadsheet.num_random_tests : 0
end

def container_test_random_seed(solution)
solution.is_random_seed_fixed ? solution.test_spreadsheet.test_random_seed : nil
end

def container_test_timestamp(solution)
solution.is_timestamp_fixed ? solution.test_spreadsheet.test_timestamp : nil
end

def save_container_test_metadata(container, answer_text, solutions)
test_data = {
answer: normalize_formula_text(answer_text),
solutions: solutions.map do |solution|
{
id: solution.id,
solution: normalize_formula_text(solution.solution),
variables: solution.test_spreadsheet.variables,
num_random_tests: container_test_num_random_tests(solution),
random_seed: container_test_random_seed(solution),
test_timestamp: container_test_timestamp(solution),
spreadsheet:
{
id: solution.test_spreadsheet.id,
filename: solution.test_spreadsheet.container_filename
}
}
end
Comment thread
adi-herwana-nus marked this conversation as resolved.
}.to_json
container.store_file("#{CoursemologyDockerContainer::HOME_PATH}/tests.json", test_data)
end

def save_container_test_spreadsheets(container, solutions)
solutions.each do |solution|
next unless solution.test_spreadsheet

solution.test_spreadsheet.attachment.open(binmode: true) do |file|
tar = StringIO.new(Docker::Util.create_tar({ solution.test_spreadsheet.container_filename => file.read }))
container.archive_in_stream(CoursemologyDockerContainer::HOME_PATH) do
tar.read(Excon.defaults[:chunk_size]).to_s
end
end
end
end

def process_spreadsheet_container_evaluation_results(container, solutions)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/AbcSize: Assignment Branch Condition size for process_spreadsheet_container_evaluation_results is too high. [<6, 19, 9> 21.86/20]
Metrics/CyclomaticComplexity: Cyclomatic complexity for process_spreadsheet_container_evaluation_results is too high. [8/7]
Metrics/PerceivedComplexity: Perceived complexity for process_spreadsheet_container_evaluation_results is too high. [9/8]

results = JSON.parse(container.read_file("#{CoursemologyDockerContainer::HOME_PATH}/result.json"))
results['evaluated_solutions'].map do |result|
solution = solutions.find { |s| s.id == result['solution_id'] }
SolutionEvaluationResult.new(
solution,
result['results'].all? { |r| r['correct'] } ? solution.grade : 0,
if result['results'].any? { |r| r.key?('expectedError') || r.key?('outputError') }
I18n.t('errors.course.assessment.text_response_auto_grading.grade.evaluation_failed')
else
solution.explanation
end,
result['results']
)
end
end

# Returns the spreadsheet formula solutions that matches the given answer text.
def evaluate_spreadsheet_formula_solutions(answer_text, solutions)
container = CoursemologyDockerContainer.create(
'coursemology/evaluator-image-python:3.14',
argv: ["#{CoursemologyDockerContainer::HOME_PATH}/autograde_spreadsheet.py"],
entrypoint: ['python3']
)
save_container_test_metadata(container, answer_text, solutions)
save_container_test_spreadsheets(container, solutions)
container.store_file("#{CoursemologyDockerContainer::HOME_PATH}/autograde_spreadsheet.py",
File.read(Rails.root.join('lib', 'evaluator_scripts', 'python', 'autograde_spreadsheet.py')))
container.execute_package
process_spreadsheet_container_evaluation_results(container, solutions)
ensure
container&.delete
end

# Returns the grade for a question with all matched solutions.
Expand All @@ -63,30 +191,27 @@ def find_keywords(answer_text, solutions)
#
# @param [Course::Assessment::Question::TextResponse] question The question answered by the
# student.
# @param [Array<Course::Assessment::Question::TextResponseSolution>] solutions The solutions that
# matches the student's answer.
# @param [Array<SolutionEvaluationResult>] evaluation_results The evaluation results for the student's answer.
# @return [Integer] The grade for the question.
def grade_for(question, solutions)
[solutions.map(&:grade).reduce(0, :+), question.maximum_grade].min
def grade_for(question, evaluation_results)
[evaluation_results.map(&:grade).reduce(0, :+), question.maximum_grade].min
end

# Returns the explanations for the given options.
#
# @param [Array<Course::Assessment::Question::TextResponseSolution>] solutions The solutions to
# obtain the explanations for.
# @param [Array<SolutionEvaluationResult>] evaluation_results The evaluation results for the student's answer.
# @return [Array<String>] The explanations for the given solutions.
def explanations_for(solutions)
solutions.map(&:explanation).tap(&:compact!)
def explanations_for(evaluation_results)
evaluation_results.map(&:explanation).tap(&:compact!)
end

# Mark the correctness of the answer based on solutions.
#
# @param [Course::Assessment::Question::TextResponse] question The question answered by the
# student.
# @param [Array<Course::Assessment::Question::TextResponseSolution>] solutions The solutions that
# matches the student's answer.
# @param [Array<SolutionEvaluationResult>] evaluation_results The evaluation results for the student's answer.
# @return [Boolean] correct True if the answer is correct.
def correctness_for(question, solutions)
solutions.map(&:grade).sum >= question.maximum_grade
def correctness_for(question, evaluation_results)
grade_for(question, evaluation_results) >= question.maximum_grade
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,22 @@ json.attachments answer.attachments do |attachment|
end

last_attempt = last_attempt(answer)
attempt = answer.current_answer? ? last_attempt : answer

job = attempt&.auto_grading&.job

if job
json.autograding do
json.path job_path(job) if job.submitted?
json.partial! "jobs/#{job.status}", job: job
end
end

if attempt.submitted? && !attempt.auto_grading
json.autograding do
json.status :submitted
end
end

if answer.can_read_grade?(current_ability)
json.explanation do
Expand All @@ -33,6 +49,24 @@ if answer.can_read_grade?(current_ability)
end
end

if (
can_grade || (@assessment.show_rubric_to_students? && @submission.published?)
) && last_attempt&.auto_grading&.result
graded_solutions =
last_attempt.auto_grading.result['evaluation_results'].index_by do |result|
result['solution_id']
end
json.solutionResults answer.question.specific.solutions do |solution|
result = graded_solutions[solution.id]
json.id solution.id
json.maximumGrade solution.grade.to_f
json.grade result['grade'].to_f if result
json.solution solution.solution
json.solutionType solution.solution_type
json.tests result['tests'] if result && result['tests']
end
end

# Required in response of reload_answer and submit_answer to update past answers with the latest_attempt
# Removing this check will cause it to render the latestAnswer recursively
if answer.current_answer? && !last_attempt.current_answer?
Expand Down
Loading