Spaces:
Running
Running
feat: quiz package
Browse files- quiz/__init__.py +0 -0
- quiz/__pycache__/__init__.cpython-311.pyc +0 -0
- quiz/__pycache__/engine.cpython-311.pyc +0 -0
- quiz/__pycache__/models.cpython-311.pyc +0 -0
- quiz/__pycache__/scoring.cpython-311.pyc +0 -0
- quiz/engine.py +43 -0
- quiz/models.py +69 -0
- quiz/scoring.py +41 -0
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!"
|