Changeset 752fa20 in flexograder
- Timestamp:
- 03/03/26 09:11:07 (11 hours ago)
- Branches:
- main
- Children:
- 418cd9f
- Parents:
- 6a4d3e8
- Files:
-
- 7 edited
-
examples/exams/GENERAL-C251110-B854C645BF40@001D-12345678-2026-02-20T09-20-46.452Z.json (modified) (1 diff)
-
flexograder/core_entities/exam.py (modified) (2 diffs)
-
flexograder/core_entities/exam_elements.py (modified) (4 diffs)
-
flexograder/evaluator/evaluate.py (modified) (1 diff)
-
flexograder/evaluator/submission.py (modified) (2 diffs)
-
flexograder/evaluator/submission_evaluator.py (modified) (2 diffs)
-
flexograder/static/exam.js (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
examples/exams/GENERAL-C251110-B854C645BF40@001D-12345678-2026-02-20T09-20-46.452Z.json
r6a4d3e8 r752fa20 1 1 { 2 2 "meta": { 3 "exam_flexo_id": "GENERAL-C251110-B854C645BF40@001D", 3 "exam_id": "GENERAL-C251110-B854C645BF40@001D", 4 "subtype": "Submission", 4 5 "submitted_at": "2026-02-20T09:20:46.452Z", 5 6 "candidate": { -
flexograder/core_entities/exam.py
r6a4d3e8 r752fa20 7 7 import html 8 8 9 from flexoentity import FlexoEntity, EntityType, EntityState, Domain 9 from flexoentity import FlexoEntity, EntityType, EntityState, Domain, FlexOID 10 10 from .exam_elements import ExamElement, ChoiceQuestion, IDForm, element_factory 11 11 from .question_catalog import QuestionCatalog … … 460 460 def evaluate(self, submission): 461 461 """ 462 Evaluate answers using the current layout order. 463 Falls back gracefully if a page references a missing question. 464 """ 462 Evaluate a Submission against this Exam. 463 Uses layout order and ignores non-choice elements. 464 """ 465 466 if submission.exam_id != self.flexo_id: 467 raise ValueError( 468 f"Submission exam_id {submission.exam_id} " 469 f"does not match this exam {self.flexo_id}" 470 ) 471 465 472 total_score = 0.0 466 473 max_score = 0.0 467 474 detailed = [] 468 475 476 responses = submission.responses 477 469 478 for page in self.layout.pages: 470 for qid in page.element_ids: 471 print("QID:", qid) 472 q = self.get_element_by_id(qid) 473 print("Question:", q) 474 if not q or not isinstance(q, ChoiceQuestion): 475 print("Skip") 479 for element_id in page.element_ids: 480 481 q = self.get_element_by_id(element_id) 482 483 if not isinstance(q, ChoiceQuestion): 476 484 continue 477 # prefer flexo_id as the answer key 478 479 print("FlexoID:", q.flexo_id) 480 submitted = submission.get_answer_for(q.flexo_id) 481 print("Submitted:", submitted) 482 score = q.evaluate(submitted) 485 486 qid = str(q.flexo_id) 487 submitted_values = responses.get(qid, []) 488 489 score = q.evaluate(submitted_values) 490 491 max_q_score = getattr(q, "max_score", 1.0) 492 483 493 total_score += score 484 max_score += getattr(q, "max_score", 1.0) 494 max_score += max_q_score 495 485 496 detailed.append({ 486 "element_id": qid, 497 "element_id": element_id, 498 "question_id": qid, 499 "submitted": submitted_values, 487 500 "score": score, 488 "max_score": getattr(q, "max_score", 1.0),501 "max_score": max_q_score, 489 502 }) 490 503 491 504 return { 505 "exam_id": str(self.flexo_id), 506 "submission_id": str(submission.flexo_id), 507 "candidate_id": submission.candidate_info.personal_id, 492 508 "total_score": total_score, 493 509 "max_score": max_score, -
flexograder/core_entities/exam_elements.py
r6a4d3e8 r752fa20 96 96 return hash((self.flexo_id, self.subtype)) 97 97 98 def evaluate(self, answer): 99 """Default: non-graded question.""" 98 def evaluate(self, submitted_values: list[str] | None): 100 99 return 0.0 101 100 … … 283 282 ] 284 283 284 285 285 286 def points_for(self, option_id): 286 287 for opt in self.options: … … 311 312 </div> 312 313 """ 313 def evaluate(self, answer): 314 if answer.is_null(): 314 315 316 def evaluate(self, submitted_values: list[str] | None): 317 if not submitted_values: 315 318 return 0.0 316 value = answer.get_value() 317 if isinstance(value, list):318 value = value[0] if value else None319 320 # Single choice → only first value counts 321 value = submitted_values[0] 319 322 return self.points_for(value) 320 323 … … 348 351 ) 349 352 350 def evaluate(self, answer):351 if answer.is_null():353 def evaluate(self, submitted_values: list[str] | None): 354 if not submitted_values: 352 355 return 0.0 353 354 selected = answer.get_value() or []355 if not isinstance(selected, list):356 selected = [selected]357 356 358 357 total = 0.0 359 358 for opt in self.options: 360 if opt.id in s elected:359 if opt.id in submitted_values: 361 360 total += opt.points 361 362 362 return max(total, 0.0) 363 363 -
flexograder/evaluator/evaluate.py
r6a4d3e8 r752fa20 5 5 6 6 7 from builder.exam import Exam7 from flexograder.core_entities.exam import Exam 8 8 from submission import Submission 9 9 -
flexograder/evaluator/submission.py
r6a4d3e8 r752fa20 1 1 import json 2 from dataclasses import dataclass 3 4 from flexoentity import FlexoEntity, FlexOID, EntityType, EntityState 2 5 3 6 4 class Answer: 5 def __init__(self, id, value, fields):6 self.id = id7 self.value = value8 self.fields = fields or []7 @dataclass 8 class CandidateInfo: 9 first_name: str 10 last_name: str 11 personal_id: str 9 12 10 def get_value(self): 11 return self.value 12 13 def is_null(self) -> bool: 14 return False 15 16 def __repr__(self): 17 return f"<Answer id={self.id!r} value={self.value!r}>" 18 19 20 class NullAnswer(Answer): 21 def __init__(self): 22 super().__init__(id="", value=None, fields=None) 23 24 def get_value(self): 25 return None 26 27 def is_null(self) -> bool: 28 return True 29 30 def __bool__(self): 31 return False 32 33 def __repr__(self): 34 return "<NullAnswer>" 35 36 37 class Submission: 38 def __init__(self, meta, answers): 39 self._meta = meta 40 self.answers = answers 13 class Submission(FlexoEntity): 41 14 42 15 @classmethod 43 def from_dict(cls, data): 44 meta = data.get("meta", {}) 45 responses = data.get("responses", {}) 16 def default(cls): 17 raise NotImplementedError("Submission has no default instance.") 18 19 def __init__(self, *, flexo_id: FlexOID, exam_id: FlexOID, 20 candidate_info: CandidateInfo, responses: dict[str, list[str]], 21 originator_id: str, fingerprint: str | None = None, 22 origin: str | None = None): 23 super().__init__( 24 flexo_id=flexo_id, 25 origin=origin, 26 originator_id=originator_id, 27 fingerprint=fingerprint, 28 ) 46 29 47 30 if not isinstance(responses, dict): 48 raise ValueError("responses must be a dict {question_id: [values]}")31 raise ValueError("responses must be dict[str, list[str]]") 49 32 50 answers = [] 33 self.exam_id = exam_id 34 self.candidate_info = candidate_info 35 self._responses = responses 51 36 52 for qid, selections in responses.items(): 53 if not isinstance(selections, list): 37 @property 38 def text_seed(self): 39 return "" 40 41 @property 42 def responses(self) -> dict[str, list[str]]: 43 return self._responses 44 45 # ───────────────────────────────────────────── 46 # from_dict 47 # ───────────────────────────────────────────── 48 49 @classmethod 50 def from_dict(cls, data: dict) -> "Submission": 51 if not isinstance(data, dict): 52 raise ValueError("Submission JSON must be a dict") 53 54 meta = data.get("meta") 55 responses = data.get("responses") 56 57 if meta is None: 58 raise ValueError("Missing 'meta' section") 59 60 if responses is None: 61 raise ValueError("Missing 'responses' section") 62 63 if meta.get("subtype") != "Submission": 64 raise ValueError("JSON is not a Submission subtype") 65 66 # --- exam_id --- 67 exam_id_str = meta.get("exam_id") 68 if not exam_id_str: 69 raise ValueError("Missing exam_id") 70 71 exam_id = FlexOID(exam_id_str) 72 73 # --- submitted_at --- 74 submitted_at = meta.get("submitted_at") 75 if not submitted_at: 76 raise ValueError("Missing submitted_at") 77 78 # --- candidate --- 79 candidate_raw = meta.get("candidate") 80 if not isinstance(candidate_raw, dict): 81 raise ValueError("Missing or invalid candidate block") 82 83 candidate_info = CandidateInfo( 84 first_name=candidate_raw.get("first_name", ""), 85 last_name=candidate_raw.get("last_name", ""), 86 personal_id=candidate_raw.get("personal_id", ""), 87 ) 88 89 # --- responses validation --- 90 if not isinstance(responses, dict): 91 raise ValueError("responses must be dict[str, list[str]]") 92 93 for qid, values in responses.items(): 94 if not isinstance(values, list): 54 95 raise ValueError( 55 f"Invalid response for {qid}: expected list , got {type(selections)}"96 f"Invalid response for {qid}: expected list[str]" 56 97 ) 57 98 58 # Keep empty answers if you want auditability59 answers.append(60 Answer(61 id=qid,62 value=selections, # list[str]63 fields=[],64 )99 if meta.get("flexo_id", None) is None: 100 flexo_id = FlexOID.safe_generate( 101 domain_id=exam_id.domain_id, 102 entity_type=EntityType.RECORD.value, 103 state=EntityState.DRAFT.value, 104 text="", 105 version=1, 65 106 ) 66 107 67 return cls(meta=meta, answers=answers) 108 return cls( 109 flexo_id=flexo_id, 110 exam_id=exam_id, 111 originator_id=None, 112 candidate_info=candidate_info, 113 responses=responses, 114 ) 115 116 # ───────────────────────────────────────────── 117 # from_json 118 # ───────────────────────────────────────────── 68 119 69 120 @classmethod … … 73 124 except json.JSONDecodeError as e: 74 125 raise ValueError(f"Invalid JSON input: {e}") 126 75 127 return cls.from_dict(data) 76 128 77 def get_answer_for(self, qid: str) -> Answer: 78 print("Answers:", self.answers) 79 return next((a for a in self.answers if a.id == qid), NullAnswer()) 80 81 def __str__(self): 82 return f"<Submission test_id={self._meta.get('test_id', '?')} answers={self.answers}>" 83 84 def __repr__(self): 85 return f"<Submission test_id={self._meta.get('test_id', '?')} answers={len(self.answers)}>" 129 # ───────────────────────────────────────────── 130 # from_json_file 131 # ───────────────────────────────────────────── 86 132 87 133 @classmethod 88 def from_json_file(cls, file_name ):134 def from_json_file(cls, file_name: str) -> "Submission": 89 135 with open(file_name, "r", encoding="utf-8") as f: 90 136 data = json.load(f) 91 137 return cls.from_dict(data) 92 93 @property94 def meta(self):95 return self._meta -
flexograder/evaluator/submission_evaluator.py
r6a4d3e8 r752fa20 27 27 result = { 28 28 "exam_id": exam.flexo_id, 29 "candidate_name": submission. meta.get("candidate")["flexo_id"],29 "candidate_name": submission.candidate_info.last_name, 30 30 "score": score, 31 31 "max_score": max_score, … … 34 34 35 35 # Write a simple report file (JSON) 36 report_path = self.output_dir / f"{submission.meta.get('candidate_name', 'unknown')}_result.json"36 report_path = self.output_dir / "test_result.json" 37 37 with report_path.open("w", encoding="utf-8") as f: 38 38 json.dump(result, f, indent=2, ensure_ascii=False) -
flexograder/static/exam.js
r6a4d3e8 r752fa20 1 1 (() => { 2 let currentPage = 0;2 let currentPage = 0; 3 3 4 const pages = document.querySelectorAll(".page");5 const navLinks = document.querySelectorAll(".nav-link");6 const form = document.getElementById("examForm");7 const body = document.body;4 const pages = document.querySelectorAll(".page"); 5 const navLinks = document.querySelectorAll(".nav-link"); 6 const form = document.getElementById("examForm"); 7 const body = document.body; 8 8 9 const examFlexoId = body.dataset.examFlexoId || "UNKNOWN"; 9 const examFlexoId = body.dataset.examFlexoId || "UNKNOWN"; 10 const subType = "Submission"; 10 11 11 // ───────────────────────────────────────────── 12 // Page handling 13 // ───────────────────────────────────────────── 12 // ───────────────────────────────────────────── 13 // Page handling 14 // ───────────────────────────────────────────── 15 16 function showPage(index) { 17 pages.forEach((p, i) => { 18 p.classList.toggle("active", i === index); 19 }); 20 currentPage = index; 21 updateStudentName(); 22 } 14 23 15 function showPage(index) { 16 pages.forEach((p, i) => { 17 p.classList.toggle("active", i === index); 24 function validateCurrentPage() { 25 const page = pages[currentPage]; 26 if (!page) return true; 27 28 const inputs = page.querySelectorAll("input, select, textarea"); 29 for (const input of inputs) { 30 if (!input.reportValidity()) { 31 return false; 32 } 33 } 34 return true; 35 } 36 37 // ───────────────────────────────────────────── 38 // Candidate name display (IDForm support) 39 // ───────────────────────────────────────────── 40 41 function updateStudentName() { 42 const first = 43 document.querySelector('input[name="first_name"]')?.value || ""; 44 const last = 45 document.querySelector('input[name="last_name"]')?.value || ""; 46 47 const fullName = `${first} ${last}`.trim(); 48 const display = document.getElementById("studentNameDisplay"); 49 50 if (display) { 51 display.textContent = fullName || "(unbekannt)"; 52 } 53 } 54 55 // ───────────────────────────────────────────── 56 // Answer collection 57 // ───────────────────────────────────────────── 58 59 function collectAnswers() { 60 const responses = {}; 61 62 document.querySelectorAll(".exam-element").forEach(el => { 63 const qid = el.dataset.qid; 64 const type = el.dataset.qtype; 65 let values = []; 66 67 if (!qid || !type) return; 68 69 switch (type) { 70 case "SingleChoiceQuestion": { 71 const sel = el.querySelector( 72 'input[type="radio"]:checked' 73 ); 74 if (sel) values = [sel.value]; 75 break; 76 } 77 78 case "MultipleChoiceQuestion": { 79 values = Array.from( 80 el.querySelectorAll('input[type="checkbox"]:checked') 81 ).map(cb => cb.value); 82 break; 83 } 84 85 case "TextQuestion": { 86 const input = el.querySelector("input, textarea"); 87 if (input && input.value) { 88 values = [input.value]; 89 } 90 break; 91 } 92 93 // InstructionBlock, IDForm, etc. 94 default: 95 break; 96 } 97 98 responses[qid] = values; 99 }); 100 101 return responses; 102 } 103 104 // ───────────────────────────────────────────── 105 // Export 106 // ───────────────────────────────────────────── 107 108 function exportAnswers() { 109 const responses = collectAnswers(); 110 111 const result = { 112 meta: { 113 exam_id: examFlexoId, 114 subtype: subtype; 115 submitted_at: new Date().toISOString(), 116 candidate: { 117 flexo_id: 118 document.querySelector('input[name="personal_id"]')?.value || 119 "UNKNOWN", 120 first_name: 121 document.querySelector('input[name="first_name"]')?.value || "", 122 last_name: 123 document.querySelector('input[name="last_name"]')?.value || "" 124 } 125 }, 126 responses 127 }; 128 129 const timestamp = new Date() 130 .toISOString() 131 .replaceAll(":", "-"); 132 133 const filename = 134 examFlexoId + 135 "-" + 136 (result.meta.candidate.flexo_id || "UNKNOWN") + 137 "-" + 138 timestamp + 139 ".json"; 140 141 const blob = new Blob( 142 [JSON.stringify(result, null, 2)], 143 { type: "application/json" } 144 ); 145 146 const url = URL.createObjectURL(blob); 147 const link = document.createElement("a"); 148 link.href = url; 149 link.download = filename; 150 document.body.appendChild(link); 151 link.click(); 152 document.body.removeChild(link); 153 URL.revokeObjectURL(url); 154 155 // lock exam after submission 156 form 157 .querySelectorAll("input, button, select, textarea") 158 .forEach(el => (el.disabled = true)); 159 } 160 161 // ───────────────────────────────────────────── 162 // Event wiring 163 // ───────────────────────────────────────────── 164 165 document.querySelectorAll(".nextBtn").forEach(btn => 166 btn.addEventListener("click", () => { 167 if (!validateCurrentPage()) return; 168 showPage(currentPage + 1); 169 }) 170 ); 171 172 document.querySelectorAll(".prevBtn").forEach(btn => 173 btn.addEventListener("click", () => { 174 showPage(currentPage - 1); 175 }) 176 ); 177 178 navLinks.forEach(link => 179 link.addEventListener("click", e => { 180 e.preventDefault(); 181 const target = Number(link.dataset.index); 182 if (Number.isNaN(target)) return; 183 if (target > currentPage && !validateCurrentPage()) return; 184 showPage(target); 185 }) 186 ); 187 188 form.addEventListener("submit", e => { 189 e.preventDefault(); 190 exportAnswers(); 18 191 }); 19 currentPage = index; 20 updateStudentName(); 21 } 22 23 function validateCurrentPage() { 24 const page = pages[currentPage]; 25 if (!page) return true; 26 27 const inputs = page.querySelectorAll("input, select, textarea"); 28 for (const input of inputs) { 29 if (!input.reportValidity()) { 30 return false; 31 } 32 } 33 return true; 34 } 35 36 // ───────────────────────────────────────────── 37 // Candidate name display (IDForm support) 38 // ───────────────────────────────────────────── 39 40 function updateStudentName() { 41 const first = 42 document.querySelector('input[name="first_name"]')?.value || ""; 43 const last = 44 document.querySelector('input[name="last_name"]')?.value || ""; 45 46 const fullName = `${first} ${last}`.trim(); 47 const display = document.getElementById("studentNameDisplay"); 48 49 if (display) { 50 display.textContent = fullName || "(unbekannt)"; 51 } 52 } 53 54 // ───────────────────────────────────────────── 55 // Answer collection 56 // ───────────────────────────────────────────── 57 58 function collectAnswers() { 59 const responses = {}; 60 61 document.querySelectorAll(".exam-element").forEach(el => { 62 const qid = el.dataset.qid; 63 const type = el.dataset.qtype; 64 let values = []; 65 66 if (!qid || !type) return; 67 68 switch (type) { 69 case "SingleChoiceQuestion": { 70 const sel = el.querySelector( 71 'input[type="radio"]:checked' 72 ); 73 if (sel) values = [sel.value]; 74 break; 75 } 76 77 case "MultipleChoiceQuestion": { 78 values = Array.from( 79 el.querySelectorAll('input[type="checkbox"]:checked') 80 ).map(cb => cb.value); 81 break; 82 } 83 84 case "TextQuestion": { 85 const input = el.querySelector("input, textarea"); 86 if (input && input.value) { 87 values = [input.value]; 88 } 89 break; 90 } 91 92 // InstructionBlock, IDForm, etc. 93 default: 94 break; 95 } 96 97 responses[qid] = values; 98 }); 99 100 return responses; 101 } 102 103 // ───────────────────────────────────────────── 104 // Export 105 // ───────────────────────────────────────────── 106 107 function exportAnswers() { 108 const responses = collectAnswers(); 109 110 const result = { 111 meta: { 112 exam_flexo_id: examFlexoId, 113 submitted_at: new Date().toISOString(), 114 candidate: { 115 flexo_id: 116 document.querySelector('input[name="personal_id"]')?.value || 117 "UNKNOWN", 118 first_name: 119 document.querySelector('input[name="first_name"]')?.value || "", 120 last_name: 121 document.querySelector('input[name="last_name"]')?.value || "" 122 } 123 }, 124 responses 125 }; 126 127 const timestamp = new Date() 128 .toISOString() 129 .replaceAll(":", "-"); 130 131 const filename = 132 examFlexoId + 133 "-" + 134 (result.meta.candidate.flexo_id || "UNKNOWN") + 135 "-" + 136 timestamp + 137 ".json"; 138 139 const blob = new Blob( 140 [JSON.stringify(result, null, 2)], 141 { type: "application/json" } 142 ); 143 144 const url = URL.createObjectURL(blob); 145 const link = document.createElement("a"); 146 link.href = url; 147 link.download = filename; 148 document.body.appendChild(link); 149 link.click(); 150 document.body.removeChild(link); 151 URL.revokeObjectURL(url); 152 153 // lock exam after submission 154 form 155 .querySelectorAll("input, button, select, textarea") 156 .forEach(el => (el.disabled = true)); 157 } 158 159 // ───────────────────────────────────────────── 160 // Event wiring 161 // ───────────────────────────────────────────── 162 163 document.querySelectorAll(".nextBtn").forEach(btn => 164 btn.addEventListener("click", () => { 165 if (!validateCurrentPage()) return; 166 showPage(currentPage + 1); 167 }) 168 ); 169 170 document.querySelectorAll(".prevBtn").forEach(btn => 171 btn.addEventListener("click", () => { 172 showPage(currentPage - 1); 173 }) 174 ); 175 176 navLinks.forEach(link => 177 link.addEventListener("click", e => { 178 e.preventDefault(); 179 const target = Number(link.dataset.index); 180 if (Number.isNaN(target)) return; 181 if (target > currentPage && !validateCurrentPage()) return; 182 showPage(target); 183 }) 184 ); 185 186 form.addEventListener("submit", e => { 187 e.preventDefault(); 188 exportAnswers(); 189 }); 190 191 // initial state 192 showPage(0); 192 193 // initial state 194 showPage(0); 193 195 })();
Note:
See TracChangeset
for help on using the changeset viewer.
