SolusOps commited on
Commit
676d6d1
·
verified ·
1 Parent(s): 4040c6b

feat: quiz package

Browse files
quiz/__init__.py ADDED
File without changes
quiz/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (174 Bytes). View file
 
quiz/__pycache__/engine.cpython-311.pyc ADDED
Binary file (2.95 kB). View file
 
quiz/__pycache__/models.cpython-311.pyc ADDED
Binary file (4.68 kB). View file
 
quiz/__pycache__/scoring.cpython-311.pyc ADDED
Binary file (2.41 kB). View file
 
quiz/engine.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from quiz.models import QuizSession
4
+ from quiz.scoring import xp_for_question, streak_label, compute_grade
5
+
6
+ @dataclass
7
+ class AnswerResult:
8
+ is_correct: bool
9
+ correct_idx: int
10
+ xp_delta: int
11
+ streak_label: str
12
+ grade: str
13
+ was_boss: bool
14
+
15
+ class QuizEngine:
16
+ def submit_next(self, session: QuizSession, selected_idx: int) -> AnswerResult:
17
+ """Submit answer for the current question (regular or boss — auto-detected via is_boss)."""
18
+ if session.is_finished:
19
+ raise RuntimeError("Cannot submit answer: session is finished.")
20
+ question = session.current_question
21
+ is_correct = (selected_idx == question.correct_idx)
22
+ xp_delta = 0
23
+
24
+ if is_correct:
25
+ session.score += 1
26
+ xp_delta = xp_for_question(question.difficulty, is_boss=question.is_boss)
27
+ session.xp_earned += xp_delta
28
+ if not question.is_boss:
29
+ session.consecutive_correct += 1
30
+ else:
31
+ session.consecutive_correct = 0
32
+ if question.topic not in session.wrong_topics:
33
+ session.wrong_topics.append(question.topic)
34
+
35
+ session.current_idx += 1
36
+ label = streak_label(session.consecutive_correct)
37
+ grade = compute_grade(session.score, session.current_idx)
38
+ return AnswerResult(is_correct=is_correct, correct_idx=question.correct_idx,
39
+ xp_delta=xp_delta, streak_label=label, grade=grade,
40
+ was_boss=question.is_boss)
41
+
42
+ def final_grade(self, session: QuizSession) -> str:
43
+ return compute_grade(session.score, len(session.questions))
quiz/models.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+ from typing import List
4
+ import time
5
+
6
+ VALID_DIFFICULTIES = {"easy", "medium", "hard"}
7
+ VALID_TYPES = {"mcq", "tf", "fill"}
8
+
9
+ @dataclass
10
+ class Question:
11
+ text: str
12
+ topic: str # subject area — "Determinants" not question text
13
+ options: List[str]
14
+ correct_idx: int
15
+ explanation: str
16
+ difficulty: str
17
+ q_type: str
18
+ is_boss: bool = False
19
+ source_excerpt: str = "" # snippet from original material for tutor context
20
+ language: str = "en"
21
+
22
+ def __post_init__(self):
23
+ if self.difficulty not in VALID_DIFFICULTIES:
24
+ raise ValueError(f"difficulty must be one of {VALID_DIFFICULTIES}, got '{self.difficulty}'")
25
+ if self.q_type not in VALID_TYPES:
26
+ raise ValueError(f"q_type must be one of {VALID_TYPES}, got '{self.q_type}'")
27
+ if not (0 <= self.correct_idx < len(self.options)):
28
+ raise ValueError(f"correct_idx {self.correct_idx} out of range for {len(self.options)} options")
29
+
30
+ @property
31
+ def correct_answer(self) -> str:
32
+ return self.options[self.correct_idx]
33
+
34
+ @dataclass
35
+ class QuizSession:
36
+ quest_name: str
37
+ questions: List[Question] = field(default_factory=list)
38
+ current_idx: int = 0
39
+ score: int = 0
40
+ consecutive_correct: int = 0
41
+ xp_earned: int = 0
42
+ start_time: float = field(default_factory=time.time)
43
+ wrong_topics: List[str] = field(default_factory=list) # topic strings, not question text
44
+
45
+ @property
46
+ def is_finished(self) -> bool:
47
+ return self.current_idx >= len(self.questions)
48
+
49
+ @property
50
+ def current_question(self) -> Question | None:
51
+ if self.is_finished:
52
+ return None
53
+ return self.questions[self.current_idx]
54
+
55
+ @dataclass
56
+ class Quest:
57
+ name: str
58
+ topics: List[str]
59
+ boss_topic: str
60
+ difficulty: str
61
+ questions: List[Question] = field(default_factory=list)
62
+ unlocked: bool = True
63
+ completed: bool = False # True only after user finishes the quest
64
+
65
+ def has_questions(self) -> bool:
66
+ return len(self.questions) > 0
67
+
68
+ def total_questions(self) -> int:
69
+ return len(self.questions)
quiz/scoring.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ _XP_TABLE = {"easy": 10, "medium": 20, "hard": 40}
4
+ _BOSS_XP = 75
5
+ _WEAK_THRESHOLD = 0.5
6
+ _STREAK_LABELS = {0: "", 1: "Nice!", 2: "Good!", 3: "Great!", 4: "Excellent!"}
7
+
8
+ def xp_for_question(difficulty: str, is_boss: bool) -> int:
9
+ if is_boss:
10
+ return _BOSS_XP
11
+ return _XP_TABLE.get(difficulty, 10)
12
+
13
+ def streak_label(consecutive: int) -> str:
14
+ if consecutive >= 5:
15
+ return "LEGENDARY!"
16
+ return _STREAK_LABELS.get(consecutive, "")
17
+
18
+ def compute_grade(score: int, total: int) -> str:
19
+ if total == 0:
20
+ return "Apprentice"
21
+ pct = score / total * 100
22
+ if pct >= 80: return "Champion"
23
+ if pct >= 60: return "Warrior"
24
+ return "Apprentice"
25
+
26
+ def compute_mastery_delta(correct: int, total: int) -> float:
27
+ if total == 0:
28
+ return 0.0
29
+ return correct / total
30
+
31
+ def should_unlock_revision_quest(mastery: float) -> bool:
32
+ return mastery < _WEAK_THRESHOLD
33
+
34
+ def mastery_recommendation(mastery: float) -> str:
35
+ if mastery < 0.3:
36
+ return "Seriously review this topic before moving on."
37
+ if mastery < 0.5:
38
+ return "More practice needed — try the revision quest."
39
+ if mastery < 0.8:
40
+ return "Getting there — a few more battles will sharpen this."
41
+ return "Strong mastery — ready for the next challenge!"