-
Notifications
You must be signed in to change notification settings - Fork 78
Spreadsheet (Excel) Text Response Autograding #8422
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
970d75e
2d75150
65fc851
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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] |
||
| 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 | ||
|
adi-herwana-nus marked this conversation as resolved.
|
||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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] |
||
| 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. | ||
|
|
@@ -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 | ||
Uh oh!
There was an error while loading. Please reload this page.