Changeset 752fa20 in flexograder


Ignore:
Timestamp:
03/03/26 09:11:07 (11 hours ago)
Author:
Enrico Schwass <ennoausberlin@…>
Branches:
main
Children:
418cd9f
Parents:
6a4d3e8
Message:

make Submission a first class entity and fix evaluate for ExamElement and Exam accordingly

Files:
7 edited

Legend:

Unmodified
Added
Removed
  • examples/exams/GENERAL-C251110-B854C645BF40@001D-12345678-2026-02-20T09-20-46.452Z.json

    r6a4d3e8 r752fa20  
    11{
    22  "meta": {
    3     "exam_flexo_id": "GENERAL-C251110-B854C645BF40@001D",
     3    "exam_id": "GENERAL-C251110-B854C645BF40@001D",
     4    "subtype": "Submission",
    45    "submitted_at": "2026-02-20T09:20:46.452Z",
    56    "candidate": {
  • flexograder/core_entities/exam.py

    r6a4d3e8 r752fa20  
    77import html
    88
    9 from flexoentity import FlexoEntity, EntityType, EntityState, Domain
     9from flexoentity import FlexoEntity, EntityType, EntityState, Domain, FlexOID
    1010from .exam_elements import ExamElement, ChoiceQuestion, IDForm, element_factory
    1111from .question_catalog import QuestionCatalog
     
    460460    def evaluate(self, submission):
    461461        """
    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
    465472        total_score = 0.0
    466473        max_score = 0.0
    467474        detailed = []
    468475
     476        responses = submission.responses
     477
    469478        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):
    476484                    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
    483493                total_score += score
    484                 max_score += getattr(q, "max_score", 1.0)
     494                max_score += max_q_score
     495
    485496                detailed.append({
    486                     "element_id": qid,
     497                    "element_id": element_id,
     498                    "question_id": qid,
     499                    "submitted": submitted_values,
    487500                    "score": score,
    488                     "max_score": getattr(q, "max_score", 1.0),
     501                    "max_score": max_q_score,
    489502                })
    490503
    491504        return {
     505            "exam_id": str(self.flexo_id),
     506            "submission_id": str(submission.flexo_id),
     507            "candidate_id": submission.candidate_info.personal_id,
    492508            "total_score": total_score,
    493509            "max_score": max_score,
  • flexograder/core_entities/exam_elements.py

    r6a4d3e8 r752fa20  
    9696        return hash((self.flexo_id, self.subtype))
    9797
    98     def evaluate(self, answer):
    99         """Default: non-graded question."""
     98    def evaluate(self, submitted_values: list[str] | None):
    10099        return 0.0
    101100
     
    283282        ]
    284283
     284
     285       
    285286    def points_for(self, option_id):
    286287        for opt in self.options:
     
    311312        </div>
    312313        """
    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:
    315318            return 0.0
    316         value = answer.get_value()
    317         if isinstance(value, list):
    318             value = value[0] if value else None
     319
     320        # Single choice → only first value counts
     321        value = submitted_values[0]
    319322        return self.points_for(value)
    320323
     
    348351        )
    349352
    350     def evaluate(self, answer):
    351         if answer.is_null():
     353    def evaluate(self, submitted_values: list[str] | None):
     354        if not submitted_values:
    352355            return 0.0
    353 
    354         selected = answer.get_value() or []
    355         if not isinstance(selected, list):
    356             selected = [selected]
    357356
    358357        total = 0.0
    359358        for opt in self.options:
    360             if opt.id in selected:
     359            if opt.id in submitted_values:
    361360                total += opt.points
     361
    362362        return max(total, 0.0)
    363363
  • flexograder/evaluator/evaluate.py

    r6a4d3e8 r752fa20  
    55
    66
    7 from builder.exam import Exam
     7from flexograder.core_entities.exam import Exam
    88from submission import Submission
    99
  • flexograder/evaluator/submission.py

    r6a4d3e8 r752fa20  
    11import json
     2from dataclasses import dataclass
     3
     4from flexoentity import FlexoEntity, FlexOID, EntityType, EntityState
    25
    36
    4 class Answer:
    5     def __init__(self, id, value, fields):
    6         self.id = id
    7         self.value = value
    8         self.fields = fields or []
     7@dataclass
     8class CandidateInfo:
     9    first_name: str
     10    last_name: str
     11    personal_id: str
    912
    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
     13class Submission(FlexoEntity):
    4114
    4215    @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        )
    4629
    4730        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]]")
    4932
    50         answers = []
     33        self.exam_id = exam_id
     34        self.candidate_info = candidate_info
     35        self._responses = responses
    5136
    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):
    5495                raise ValueError(
    55                     f"Invalid response for {qid}: expected list, got {type(selections)}"
     96                    f"Invalid response for {qid}: expected list[str]"
    5697                )
    5798
    58             # Keep empty answers if you want auditability
    59             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,
    65106            )
    66107
    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    # ─────────────────────────────────────────────
    68119
    69120    @classmethod
     
    73124        except json.JSONDecodeError as e:
    74125            raise ValueError(f"Invalid JSON input: {e}")
     126
    75127        return cls.from_dict(data)
    76128
    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    # ─────────────────────────────────────────────
    86132
    87133    @classmethod
    88     def from_json_file(cls, file_name):
     134    def from_json_file(cls, file_name: str) -> "Submission":
    89135        with open(file_name, "r", encoding="utf-8") as f:
    90136            data = json.load(f)
    91137        return cls.from_dict(data)
    92 
    93     @property
    94     def meta(self):
    95         return self._meta
  • flexograder/evaluator/submission_evaluator.py

    r6a4d3e8 r752fa20  
    2727        result = {
    2828            "exam_id": exam.flexo_id,
    29             "candidate_name": submission.meta.get("candidate")["flexo_id"],
     29            "candidate_name": submission.candidate_info.last_name,
    3030            "score": score,
    3131            "max_score": max_score,
     
    3434
    3535        # 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"
    3737        with report_path.open("w", encoding="utf-8") as f:
    3838            json.dump(result, f, indent=2, ensure_ascii=False)
  • flexograder/static/exam.js

    r6a4d3e8 r752fa20  
    11(() => {
    2   let currentPage = 0;
     2    let currentPage = 0;
    33
    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;
    88
    9   const examFlexoId = body.dataset.examFlexoId || "UNKNOWN";
     9    const examFlexoId = body.dataset.examFlexoId || "UNKNOWN";
     10    const subType = "Submission";
    1011
    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    }
    1423
    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();
    18191    });
    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);
    193195})();
Note: See TracChangeset for help on using the changeset viewer.