diff --git a/app/assets/javascripts/dag.js b/app/assets/javascripts/dag.js index 3aaae79e..097d1467 100644 --- a/app/assets/javascripts/dag.js +++ b/app/assets/javascripts/dag.js @@ -1,106 +1,97 @@ -// 根据 DAG 描述构建图 function buildGraph(dag, nodeValueToLabel) { - const graph = {}; - const lines = dag.trim().split('\n'); - - for (const line of lines) { - const [node, ...parents] = line.split(':').map(item => item.trim()); - const label = nodeValueToLabel[node]; - if (parents.length === 0 || parents[0] === '') { - graph[label] = []; - } else { - const validParents = parents.map(parent => nodeValueToLabel[parent]).filter(parent => parent !== ''); - graph[label] = validParents; - } - } - - return graph; + const graph = {}; + const inDegree = {}; + + // initialize graph and in-degree + for (const nodeLabel in nodeValueToLabel) { + graph[nodeLabel] = []; + inDegree[nodeLabel] = 0; } - - // 构建入度数组并初始化队列 - function initializeCounts(graph) { - const inDegree = {}; - const queue = []; - - for (const node in graph) { - inDegree[node] = graph[node].length; - if (inDegree[node] === 0) { - queue.push(node); + + // parse the DAG and build the graph + const lines = dag.split('\n'); + for (const line of lines) { + const parts = line.split(':').map(part => part.trim()); + if (parts.length === 2) { + const nodeLabel = parts[0]; + const dependencies = parts[1].split(' ').filter(label => label !== ''); + for (const dependency of dependencies) { + if (dependency !== '-1' && nodeValueToLabel[nodeLabel] !== undefined && nodeValueToLabel[dependency] !== undefined) { + graph[nodeLabel].push(dependency); // add dependency to the graph + inDegree[dependency]++; // increment in-degree of the dependency + } } } - - return { inDegree, queue }; } - - function processSolution(graph, inDegree, queue, solution, nodeValueToLabel) { - const visited = new Set(); - if (Array.isArray(solution)) { - solution = solution.join('\n'); - } else if (typeof solution !== 'string') { - throw new TypeError('The solution must be a string or an array.'); - } - - const solutionNodes = solution.split('\n').map(line => line.trim()); - const graphNodes = Object.keys(graph).filter(node => node !== '__root__'); // 排除虚拟根节点 - - console.log("Solution nodes:", solutionNodes); - console.log("Graph nodes:", graphNodes); - - // 检查学生的解答中的项目数量是否与图中的节点数量匹配 - if (solutionNodes.length !== graphNodes.length) { - throw new Error('Number of items in student solution does not match the number of nodes in the graph.'); + console.log("Graph:", graph); + console.log("In-degree:", inDegree); + return { graph, inDegree }; +} + + +function processSolution(solution, graph, inDegree, nodeValueToLabel) { + console.log("processSolution:", solution); + console.log("processnodeValueToLabel:", nodeValueToLabel); + const visited = new Set(); + + for (const nodeText of solution) { + const nodeLabel = Object.keys(nodeValueToLabel).find( + (label) => nodeValueToLabel[label] === nodeText + ); + + if (nodeLabel === undefined) { + console.log("Skipping node not found in nodeValueToLabel:", nodeText); + continue; // jump to the next node } - - for (const node of solutionNodes) { // 修改这里 - console.log("Current node:", node); - console.log("Current queue:", queue); - - // 查找节点对应的标签 - const label = node; // 修改这里 - if (!label) { - console.log("Node label not found, returning false"); - return false; - } - - // 如果当前节点的标签不在队列中,返回false - if (!queue.includes(label)) { - console.log("Node label not in queue, returning false"); + + console.log('Current label:', nodeLabel); + console.log('Current node text:', nodeText); + console.log('Node value to label mapping:', nodeValueToLabel); + + visited.add(nodeLabel); + + // check if the node has dependencies + for (const dependencyLabel of graph[nodeLabel]) { + if (!visited.has(dependencyLabel)) { + console.error("Dependency not satisfied:", nodeText, "depends on", nodeValueToLabel[dependencyLabel]); return false; } - - // 将当前节点的标签从队列中移除 - queue.splice(queue.indexOf(label), 1); - visited.add(label); - - // 更新相邻节点的入度,并将入度变为0的节点加入队列 - for (const neighbor in graph) { - if (graph[neighbor].includes(label)) { - inDegree[neighbor]--; - if (inDegree[neighbor] === 0) { - queue.push(neighbor); - } - } - } - console.log("Updated in-degree:", inDegree); - console.log("Updated queue:", queue); } - - // 如果所有节点都被访问过,返回true,否则返回false - const allVisited = visited.size === Object.keys(graph).length; - console.log("All nodes visited:", allVisited); - return allVisited; } + + // check if all nodes were visited + if (visited.size !== Object.keys(nodeValueToLabel).length) { + console.error("Not all nodes in nodeValueToLabel were visited."); + return false; + } + + console.log('Visited nodes:', Array.from(visited)); + return true; +} + + + - function processDAG(dag, solution) { - const nodeValueToLabel = { - "one": "print('Hello')", - "two": "print('Parsons')", - "three": "print('Problems!')" - }; - - const graph = buildGraph(dag, nodeValueToLabel); - const { inDegree, queue } = initializeCounts(graph); - const result = processSolution(graph, inDegree, queue, solution, nodeValueToLabel); - return result; +function processDAG(dag, solution, nodeValueToLabel) { + console.log("DAG:", dag); + console.log("Node value to label mapping:", nodeValueToLabel); + const { graph, inDegree } = buildGraph(dag, nodeValueToLabel); + const result = processSolution(solution, graph, inDegree, nodeValueToLabel); + return result; +} + +function extractCode(solution, nodeValueToLabel) { + const code = []; + const newNodeValueToLabel = {}; + for (const nodeText of solution) { + const nodeLabel = Object.keys(nodeValueToLabel).find( + (key) => nodeValueToLabel[key] === nodeText + ); + if (nodeLabel !== undefined) { + code.push(nodeText); + newNodeValueToLabel[nodeLabel] = nodeText; + } + } + return { code, newNodeValueToLabel }; } \ No newline at end of file diff --git a/app/controllers/exercises_controller.rb b/app/controllers/exercises_controller.rb index 2902e7d8..b32f7c3a 100644 --- a/app/controllers/exercises_controller.rb +++ b/app/controllers/exercises_controller.rb @@ -518,50 +518,37 @@ def upload_create text_representation = File.read(params[:form][:file].path) use_rights = 0 # Personal exercise end - - # 检查 text_representation 中的 tags.style 字段是否包含 "parsons" - if text_representation.include?("tags.style") && text_representation.include?("parsons") - # 使用自定义解析器解析 text_representation - parsed_data = parse_text_representation(text_representation) - - # 使用 ParsonsPromptRepresenter 创建 ParsonsPrompt 对象 - parsons_prompt = ParsonsPromptRepresenter.new(ParsonsPrompt.new).from_hash(parsed_data) - exercises = [parsons_prompt.to_hash] - else - # 使用 ExerciseRepresenter 解析 text_representation - exercises = ExerciseRepresenter.for_collection.new([]).from_hash(YAML.load(text_representation)) - end - - # 后续的处理逻辑保持不变 - exercises.each do |e| - if e[:instructions].present? && e[:assets].present? - # 处理 Parsons 问题 - parsons_prompt = {} - - # 从 e 中获取相应字段的值,并赋值给 parsons_prompt - parsons_prompt["exercise_id"] = e[:exercise_id] - parsons_prompt["title"] = e[:title] - parsons_prompt["author"] = e[:author] - parsons_prompt["license"] = e[:license] - parsons_prompt["tags"] = e[:tags] - parsons_prompt["instructions"] = e[:instructions] - parsons_prompt["assets"] = e[:assets] - - # 更新 prompt - e[:prompt] = [{ "parsons_prompt" => parsons_prompt }] - - # 删除 e 中已经复制到 parsons_prompt 的字段 - e.delete(:exercise_id) - e.delete(:title) - e.delete(:author) - e.delete(:license) - e.delete(:tags) - e.delete(:instructions) - e.delete(:assets) + + begin + hash = YAML.load(text_representation) + if !hash.kind_of?(Array) + hash = [hash] + is_parsons = false + end + rescue Psych::SyntaxError + attributes = {} + text_representation.scan(/^(\w+(?:\.\w+)*):(.*)$/) do |key, value| + keys = key.split('.') + target = attributes + keys[0..-2].each do |k| + target[k] ||= {} + target = target[k] + end + target[keys.last] = value.strip + end + + title = attributes['title'] + assets_code = attributes.dig('assets', 'code', 'starter', 'files') + if assets_code + assets_code_content = assets_code['content'] + hash = assets_code_content.scan(/^tag:\s*(\w+)\s*\n^display:\s*(.+)$/m).map do |tag, display| + { 'tag' => tag, 'display' => display } + end + is_parsons = true + else + hash = [] + is_parsons = false end - end - if !hash.kind_of?(Array) - hash = [hash] end files = exercise_params[:files] @@ -601,11 +588,16 @@ def upload_create @return_to = session.delete(:return_to) || exercises_path # parse the text_representation - exercises = ExerciseRepresenter.for_collection.new([]).from_hash(hash) + if is_parsons + exercises = ParsonsExerciseRepresenter.for_collection.new([]).from_hash(hash) + else + exercises = ExerciseRepresenter.for_collection.new([]).from_hash(hash) + end success_all = true error_msgs = [] success_msgs = [] exercises.each do |e| + e.name = title if title.present? if !e.save success_all = false # put together an error message @@ -665,7 +657,7 @@ def upload_create # Notify user of success success_msgs << - "
  • X#{e.id}: #{e.name} saved, try it #{view_context.link_to 'here', exercise_practice_path(e)}.
  • " + "
  • X#{e.id}: #{e.name || 'Parsons problem'} saved, try it #{view_context.link_to 'here', exercise_practice_path(e)}.
  • " end end diff --git a/app/models/attempt.rb b/app/models/attempt.rb index 6d6548d4..a770040c 100644 --- a/app/models/attempt.rb +++ b/app/models/attempt.rb @@ -20,10 +20,12 @@ # # Indexes # -# index_attempts_on_active_score_id (active_score_id) -# index_attempts_on_exercise_version_id (exercise_version_id) -# index_attempts_on_user_id (user_id) -# index_attempts_on_workout_score_id (workout_score_id) +# idx_attempts_on_user_exercise_version (user_id,exercise_version_id) +# idx_attempts_on_workout_score_exercise_version (workout_score_id,exercise_version_id) +# index_attempts_on_active_score_id (active_score_id) +# index_attempts_on_exercise_version_id (exercise_version_id) +# index_attempts_on_user_id (user_id) +# index_attempts_on_workout_score_id (workout_score_id) # # Foreign Keys # diff --git a/app/models/exercise_version.rb b/app/models/exercise_version.rb index 1c781932..554f8fbd 100644 --- a/app/models/exercise_version.rb +++ b/app/models/exercise_version.rb @@ -54,6 +54,7 @@ class ExerciseVersion < ActiveRecord::Base has_many :resource_files, through: :ownerships belongs_to :creator, class_name: 'User' belongs_to :irt_data, dependent: :destroy + has_one :parsons_prompt #~ Hooks .................................................................... diff --git a/app/models/parsons_prompt.rb b/app/models/parsons_prompt.rb index 688e6ad2..88c4e183 100644 --- a/app/models/parsons_prompt.rb +++ b/app/models/parsons_prompt.rb @@ -1,6 +1,28 @@ +# == Schema Information +# +# Table name: parsons_prompts +# +# id :integer not null, primary key +# assets :text(65535) +# instructions :text(65535) +# title :text(65535) +# created_at :datetime +# updated_at :datetime +# exercise_id :string(255) +# exercise_version_id :integer not null +# +# Indexes +# +# fk_rails_40d6ef5b4f (exercise_version_id) +# +# Foreign Keys +# +# fk_rails_... (exercise_version_id => exercise_versions.id) +# + class ParsonsPrompt < ActiveRecord::Base belongs_to :parsons belongs_to :exercise_version - store_accessor :assets, :code, :test - end \ No newline at end of file + serialize :assets, JSON + end diff --git a/app/representers/ParsonsExerciseRepresenter.rb b/app/representers/ParsonsExerciseRepresenter.rb new file mode 100644 index 00000000..69ab4dd6 --- /dev/null +++ b/app/representers/ParsonsExerciseRepresenter.rb @@ -0,0 +1,46 @@ +class ParsonsExerciseRepresenter < Representable::Decorator + include Representable::Hash + + collection_representer instance: lambda { |options| + fragment = options[:fragment] + if fragment.has_key? 'external_id' + e = Exercise.where(external_id: fragment['external_id']).first + e || Exercise.new + else + Exercise.new + end + } + + property :name + property :external_id + property :is_public, setter: lambda { |val, *| self.is_public = val.to_b } + property :experience + property :language_list, getter: lambda { |*| language_list.to_s } + property :style_list, getter: lambda { |*| style_list.to_s } + property :tag_list, getter: lambda { |*| tag_list.to_s } + + property :current_version, class: ExerciseVersion, setter: lambda { |val, *| + self.current_version = val + self.exercise_versions << self.current_version + self.current_version.exercise = self + }, instance: lambda { |*| ExerciseVersion.new } do + property :version, setter: lambda { |*| } + property :creator, getter: lambda { |*| creator.andand.email }, setter: lambda { |val, *| + if val + self.creator = User.where(email: val).first + end + } + + property :starter_code, getter: lambda { |*| + files = assets_code_starter_files + content = files.map { |file| file['content'] }.join('\n') if files + content + } + + property :test_code, getter: lambda { |*| + files = assets_test_files + content = files['content'] if files + content + } + end + end \ No newline at end of file diff --git a/app/views/exercises/Jsparson/exercise/simple/s10.html.erb b/app/views/exercises/Jsparson/exercise/simple/s10.html.erb new file mode 100644 index 00000000..5c4eb68c --- /dev/null +++ b/app/views/exercises/Jsparson/exercise/simple/s10.html.erb @@ -0,0 +1,294 @@ + + + + Simple js-parsons example assignment + <%= stylesheet_link_tag 'parsons' %> + <%= stylesheet_link_tag 'prettify' %> + <%= stylesheet_link_tag 'odsaAV-min' %> + <%= stylesheet_link_tag 'JSAV' %> + <%= javascript_include_tag 'prettify' %> + + + +
    +

    +

    +
    +
    +
    +
    +
    +

    + Get feedback +

    Feedback

    +
    Your feedback will appear here when you check your answer.
    +

    +
    +
    + <%= javascript_include_tag 'jquery' %> + <%= javascript_include_tag 'jquery-ui' %> + <%= javascript_include_tag 'jquery.ui.touch-punch.min' %> + <%= javascript_include_tag 'dag' %> + <%= javascript_include_tag 'underscore-min' %> + <%= javascript_include_tag 'lis' %> + <%= javascript_include_tag 'parsons' %> + <%= javascript_include_tag 'skulpt' %> + <%= javascript_include_tag 'skulpt-stdlib' %> + + + + \ No newline at end of file diff --git a/app/views/exercises/Jsparson/exercise/simple/s8.html.erb b/app/views/exercises/Jsparson/exercise/simple/s8.html.erb index 76c2ae88..b0e72390 100644 --- a/app/views/exercises/Jsparson/exercise/simple/s8.html.erb +++ b/app/views/exercises/Jsparson/exercise/simple/s8.html.erb @@ -144,17 +144,15 @@ }); function extractNodeValueToLabel(pemlContent) { - const nodeValueToLabel = {}; - const regex = /tag:\s*(\w+)\s*display:\s*(['"])(.*?)\2/g; - let match; - - while ((match = regex.exec(pemlContent)) !== null) { - const tag = match[1]; - const display = match[3]; - nodeValueToLabel[tag] = display; - } - - return nodeValueToLabel; + const nodeValueToLabel = {}; + const regex = /tag:\s*(\w+)\s*\n\s*display:\s*(.+)/g; + let match; + while ((match = regex.exec(pemlContent)) !== null) { + const tag = match[1]; + const display = match[2].trim(); + nodeValueToLabel[tag] = display; + } + return nodeValueToLabel; } diff --git a/app/views/exercises/Jsparson/exercise/simple/s9.html.erb b/app/views/exercises/Jsparson/exercise/simple/s9.html.erb new file mode 100644 index 00000000..e4eefb20 --- /dev/null +++ b/app/views/exercises/Jsparson/exercise/simple/s9.html.erb @@ -0,0 +1,198 @@ + + + + Simple js-parsons example assignment + <%= stylesheet_link_tag 'parsons' %> + <%= stylesheet_link_tag 'prettify' %> + <%= stylesheet_link_tag 'odsaAV-min' %> + <%= stylesheet_link_tag 'JSAV' %> + <%= javascript_include_tag 'prettify' %> + + + +
    +

    +

    +
    +
    +
    +
    +
    +

    + Get feedback +

    Feedback

    +
    Your feedback will appear here when you check your answer.
    +

    +
    +
    + <%= javascript_include_tag 'jquery' %> + <%= javascript_include_tag 'jquery-ui' %> + <%= javascript_include_tag 'jquery.ui.touch-punch.min' %> + <%= javascript_include_tag 'dag' %> + <%= javascript_include_tag 'underscore-min' %> + <%= javascript_include_tag 'lis' %> + <%= javascript_include_tag 'parsons' %> + <%= javascript_include_tag 'skulpt' %> + <%= javascript_include_tag 'skulpt-stdlib' %> + + + + \ No newline at end of file diff --git a/db/migrate/20240413031046_create_parsons_prompts.rb b/db/migrate/20240413031046_create_parsons_prompts.rb index a0562c94..1ab16bfb 100644 --- a/db/migrate/20240413031046_create_parsons_prompts.rb +++ b/db/migrate/20240413031046_create_parsons_prompts.rb @@ -1,10 +1,10 @@ -class CreateParsonsPrompts < ActiveRecord::Migration[6.1] +class CreateParsonsPrompts < ActiveRecord::Migration def change create_table :parsons_prompts do |t| t.text :title t.text :instructions t.string :exercise_id - t.json :assets + t.text :assets t.references :exercise_version, null: false, foreign_key: true t.timestamps end diff --git a/db/schema.rb b/db/schema.rb index 10f8cec1..ecc16b4b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20240207035240) do +ActiveRecord::Schema.define(version: 20240413031046) do create_table "active_admin_comments", force: :cascade do |t| t.string "namespace", limit: 255 @@ -47,7 +47,9 @@ add_index "attempts", ["active_score_id"], name: "index_attempts_on_active_score_id", using: :btree add_index "attempts", ["exercise_version_id"], name: "index_attempts_on_exercise_version_id", using: :btree + add_index "attempts", ["user_id", "exercise_version_id"], name: "idx_attempts_on_user_exercise_version", using: :btree add_index "attempts", ["user_id"], name: "index_attempts_on_user_id", using: :btree + add_index "attempts", ["workout_score_id", "exercise_version_id"], name: "idx_attempts_on_workout_score_exercise_version", using: :btree add_index "attempts", ["workout_score_id"], name: "index_attempts_on_workout_score_id", using: :btree create_table "attempts_tag_user_scores", id: false, force: :cascade do |t| @@ -411,6 +413,18 @@ add_index "ownerships", ["exercise_version_id"], name: "index_ownerships_on_exercise_version_id", using: :btree add_index "ownerships", ["filename"], name: "index_ownerships_on_filename", using: :btree + create_table "parsons_prompts", force: :cascade do |t| + t.text "title", limit: 65535 + t.text "instructions", limit: 65535 + t.string "exercise_id", limit: 255 + t.text "assets", limit: 65535 + t.integer "exercise_version_id", limit: 4, null: false + t.datetime "created_at" + t.datetime "updated_at" + end + + add_index "parsons_prompts", ["exercise_version_id"], name: "fk_rails_40d6ef5b4f", using: :btree + create_table "prompt_answers", force: :cascade do |t| t.integer "attempt_id", limit: 4 t.integer "prompt_id", limit: 4 @@ -745,6 +759,7 @@ add_foreign_key "lms_instances", "lms_types", name: "lms_instances_lms_type_id_fk" add_foreign_key "lti_workouts", "lms_instances" add_foreign_key "ownerships", "exercise_versions" + add_foreign_key "parsons_prompts", "exercise_versions" add_foreign_key "prompt_answers", "attempts", name: "prompt_answers_attempt_id_fk" add_foreign_key "prompt_answers", "prompts", name: "prompt_answers_prompt_id_fk" add_foreign_key "prompts", "irt_data", column: "irt_data_id", name: "prompts_irt_data_id_fk" diff --git a/spec/factories/attempts.rb b/spec/factories/attempts.rb index 8c5b912f..b696476c 100644 --- a/spec/factories/attempts.rb +++ b/spec/factories/attempts.rb @@ -20,10 +20,12 @@ # # Indexes # -# index_attempts_on_active_score_id (active_score_id) -# index_attempts_on_exercise_version_id (exercise_version_id) -# index_attempts_on_user_id (user_id) -# index_attempts_on_workout_score_id (workout_score_id) +# idx_attempts_on_user_exercise_version (user_id,exercise_version_id) +# idx_attempts_on_workout_score_exercise_version (workout_score_id,exercise_version_id) +# index_attempts_on_active_score_id (active_score_id) +# index_attempts_on_exercise_version_id (exercise_version_id) +# index_attempts_on_user_id (user_id) +# index_attempts_on_workout_score_id (workout_score_id) # # Foreign Keys #