pysfi 0.1.7__py3-none-any.whl → 0.1.11__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pysfi-0.1.7.dist-info → pysfi-0.1.11.dist-info}/METADATA +11 -9
- pysfi-0.1.11.dist-info/RECORD +60 -0
- pysfi-0.1.11.dist-info/entry_points.txt +28 -0
- sfi/__init__.py +1 -1
- sfi/alarmclock/alarmclock.py +40 -40
- sfi/bumpversion/__init__.py +1 -1
- sfi/cleanbuild/cleanbuild.py +155 -0
- sfi/condasetup/condasetup.py +116 -0
- sfi/docscan/__init__.py +1 -1
- sfi/docscan/docscan.py +407 -103
- sfi/docscan/docscan_gui.py +1282 -596
- sfi/docscan/lang/eng.py +152 -0
- sfi/docscan/lang/zhcn.py +170 -0
- sfi/filedate/filedate.py +185 -112
- sfi/gittool/__init__.py +2 -0
- sfi/gittool/gittool.py +401 -0
- sfi/llmclient/llmclient.py +592 -0
- sfi/llmquantize/llmquantize.py +480 -0
- sfi/llmserver/llmserver.py +335 -0
- sfi/makepython/makepython.py +31 -30
- sfi/pdfsplit/pdfsplit.py +173 -173
- sfi/pyarchive/pyarchive.py +418 -0
- sfi/pyembedinstall/pyembedinstall.py +629 -0
- sfi/pylibpack/__init__.py +0 -0
- sfi/pylibpack/pylibpack.py +1457 -0
- sfi/pylibpack/rules/numpy.json +22 -0
- sfi/pylibpack/rules/pymupdf.json +10 -0
- sfi/pylibpack/rules/pyqt5.json +19 -0
- sfi/pylibpack/rules/pyside2.json +23 -0
- sfi/pylibpack/rules/scipy.json +23 -0
- sfi/pylibpack/rules/shiboken2.json +24 -0
- sfi/pyloadergen/pyloadergen.py +512 -227
- sfi/pypack/__init__.py +0 -0
- sfi/pypack/pypack.py +1142 -0
- sfi/pyprojectparse/__init__.py +0 -0
- sfi/pyprojectparse/pyprojectparse.py +500 -0
- sfi/pysourcepack/pysourcepack.py +308 -0
- sfi/quizbase/__init__.py +0 -0
- sfi/quizbase/quizbase.py +828 -0
- sfi/quizbase/quizbase_gui.py +987 -0
- sfi/regexvalidate/__init__.py +0 -0
- sfi/regexvalidate/regex_help.html +284 -0
- sfi/regexvalidate/regexvalidate.py +468 -0
- sfi/taskkill/taskkill.py +0 -2
- sfi/workflowengine/__init__.py +0 -0
- sfi/workflowengine/workflowengine.py +444 -0
- pysfi-0.1.7.dist-info/RECORD +0 -31
- pysfi-0.1.7.dist-info/entry_points.txt +0 -15
- sfi/embedinstall/embedinstall.py +0 -418
- sfi/projectparse/projectparse.py +0 -152
- sfi/pypacker/fspacker.py +0 -91
- {pysfi-0.1.7.dist-info → pysfi-0.1.11.dist-info}/WHEEL +0 -0
- /sfi/{embedinstall → docscan/lang}/__init__.py +0 -0
- /sfi/{projectparse → llmquantize}/__init__.py +0 -0
- /sfi/{pypacker → pyembedinstall}/__init__.py +0 -0
sfi/quizbase/quizbase.py
ADDED
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
"""Universal quiz system supporting multiple question types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import random
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from functools import cached_property
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class QuestionType(Enum):
|
|
15
|
+
"""Supported question types."""
|
|
16
|
+
|
|
17
|
+
MULTIPLE_CHOICE = "multiple_choice"
|
|
18
|
+
FILL_BLANK = "fill_blank"
|
|
19
|
+
TRUE_FALSE = "true_false"
|
|
20
|
+
ESSAY = "essay"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Question:
|
|
25
|
+
"""Base question data structure."""
|
|
26
|
+
|
|
27
|
+
question_id: str
|
|
28
|
+
question_type: QuestionType
|
|
29
|
+
question_text: str
|
|
30
|
+
points: float = 1.0
|
|
31
|
+
|
|
32
|
+
def check_answer(self, answer: Any) -> tuple[bool, str]:
|
|
33
|
+
"""Check if answer is correct. Returns (is_correct, explanation)."""
|
|
34
|
+
raise NotImplementedError
|
|
35
|
+
|
|
36
|
+
def to_dict(self) -> dict[str, Any]:
|
|
37
|
+
"""Convert question to dictionary format."""
|
|
38
|
+
return {
|
|
39
|
+
"question_id": self.question_id,
|
|
40
|
+
"question_type": self.question_type.value,
|
|
41
|
+
"question_text": self.question_text,
|
|
42
|
+
"points": self.points,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_dict(cls, data: dict[str, Any]) -> Question:
|
|
47
|
+
"""Create question instance from dictionary."""
|
|
48
|
+
qtype = QuestionType(data["question_type"])
|
|
49
|
+
|
|
50
|
+
if qtype == QuestionType.MULTIPLE_CHOICE:
|
|
51
|
+
return MultipleChoiceQuestion.from_dict(data)
|
|
52
|
+
elif qtype == QuestionType.FILL_BLANK:
|
|
53
|
+
return FillBlankQuestion.from_dict(data)
|
|
54
|
+
elif qtype == QuestionType.TRUE_FALSE:
|
|
55
|
+
return TrueFalseQuestion.from_dict(data)
|
|
56
|
+
elif qtype == QuestionType.ESSAY:
|
|
57
|
+
return EssayQuestion.from_dict(data)
|
|
58
|
+
else:
|
|
59
|
+
raise ValueError(f"Unknown question type: {qtype}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class MultipleChoiceQuestion(Question):
|
|
64
|
+
"""Multiple choice question with single or multiple answers."""
|
|
65
|
+
|
|
66
|
+
options: list[str] = field(default_factory=list)
|
|
67
|
+
correct_answer: list[int] | int = field(
|
|
68
|
+
default_factory=list
|
|
69
|
+
) # Index/indices of correct options
|
|
70
|
+
allow_multiple: bool = False
|
|
71
|
+
|
|
72
|
+
def check_answer(self, answer: int | list[int]) -> tuple[bool, str]:
|
|
73
|
+
"""Check if selected option(s) match correct answer."""
|
|
74
|
+
if isinstance(answer, int):
|
|
75
|
+
answer = [answer]
|
|
76
|
+
|
|
77
|
+
if not isinstance(self.correct_answer, list):
|
|
78
|
+
correct = [self.correct_answer]
|
|
79
|
+
else:
|
|
80
|
+
correct = self.correct_answer
|
|
81
|
+
|
|
82
|
+
is_correct = sorted(answer) == sorted(correct)
|
|
83
|
+
explanation = self._generate_explanation(answer, correct)
|
|
84
|
+
return is_correct, explanation
|
|
85
|
+
|
|
86
|
+
def _generate_explanation(self, selected: list[int], correct: list[int]) -> str:
|
|
87
|
+
"""Generate explanation for the answer."""
|
|
88
|
+
if not self.allow_multiple:
|
|
89
|
+
correct_text = self.options[correct[0]]
|
|
90
|
+
if sorted(selected) == sorted(correct):
|
|
91
|
+
return f"Correct! {correct_text} is the right answer."
|
|
92
|
+
return f"Incorrect. The correct answer is: {correct_text}"
|
|
93
|
+
|
|
94
|
+
correct_texts = [self.options[i] for i in correct]
|
|
95
|
+
if sorted(selected) == sorted(correct):
|
|
96
|
+
return f"Correct! {', '.join(correct_texts)} are the right answers."
|
|
97
|
+
return f"Incorrect. The correct answers are: {', '.join(correct_texts)}"
|
|
98
|
+
|
|
99
|
+
def to_dict(self) -> dict[str, Any]:
|
|
100
|
+
"""Convert to dictionary format."""
|
|
101
|
+
base = super().to_dict()
|
|
102
|
+
base.update({
|
|
103
|
+
"options": self.options,
|
|
104
|
+
"correct_answer": self.correct_answer,
|
|
105
|
+
"allow_multiple": self.allow_multiple,
|
|
106
|
+
})
|
|
107
|
+
return base
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def from_dict(cls, data: dict[str, Any]) -> MultipleChoiceQuestion:
|
|
111
|
+
"""Create instance from dictionary."""
|
|
112
|
+
return cls(
|
|
113
|
+
question_id=data["question_id"],
|
|
114
|
+
question_type=QuestionType.MULTIPLE_CHOICE,
|
|
115
|
+
question_text=data["question_text"],
|
|
116
|
+
points=data.get("points", 1.0),
|
|
117
|
+
options=data["options"],
|
|
118
|
+
correct_answer=data["correct_answer"],
|
|
119
|
+
allow_multiple=data.get("allow_multiple", False),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class FillBlankQuestion(Question):
|
|
125
|
+
"""Fill in the blank question."""
|
|
126
|
+
|
|
127
|
+
correct_answers: list[str] = field(
|
|
128
|
+
default_factory=list
|
|
129
|
+
) # Multiple acceptable answers
|
|
130
|
+
case_sensitive: bool = False
|
|
131
|
+
|
|
132
|
+
def check_answer(self, answer: str) -> tuple[bool, str]:
|
|
133
|
+
"""Check if answer matches any correct option."""
|
|
134
|
+
if not self.case_sensitive:
|
|
135
|
+
answer = answer.strip().lower()
|
|
136
|
+
valid_answers = [a.strip().lower() for a in self.correct_answers]
|
|
137
|
+
else:
|
|
138
|
+
answer = answer.strip()
|
|
139
|
+
valid_answers = [a.strip() for a in self.correct_answers]
|
|
140
|
+
|
|
141
|
+
is_correct = answer in valid_answers
|
|
142
|
+
if is_correct:
|
|
143
|
+
return True, f"Correct! '{answer}' is the right answer."
|
|
144
|
+
return False, f"Incorrect. Valid answers are: {', '.join(self.correct_answers)}"
|
|
145
|
+
|
|
146
|
+
def to_dict(self) -> dict[str, Any]:
|
|
147
|
+
"""Convert to dictionary format."""
|
|
148
|
+
base = super().to_dict()
|
|
149
|
+
base.update({
|
|
150
|
+
"correct_answers": self.correct_answers,
|
|
151
|
+
"case_sensitive": self.case_sensitive,
|
|
152
|
+
})
|
|
153
|
+
return base
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def from_dict(cls, data: dict[str, Any]) -> FillBlankQuestion:
|
|
157
|
+
"""Create instance from dictionary."""
|
|
158
|
+
return cls(
|
|
159
|
+
question_id=data["question_id"],
|
|
160
|
+
question_type=QuestionType.FILL_BLANK,
|
|
161
|
+
question_text=data["question_text"],
|
|
162
|
+
points=data.get("points", 1.0),
|
|
163
|
+
correct_answers=data["correct_answers"],
|
|
164
|
+
case_sensitive=data.get("case_sensitive", False),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass
|
|
169
|
+
class TrueFalseQuestion(Question):
|
|
170
|
+
"""True/False question."""
|
|
171
|
+
|
|
172
|
+
correct_answer: bool = True
|
|
173
|
+
|
|
174
|
+
def check_answer(self, answer: bool | str) -> tuple[bool, str]:
|
|
175
|
+
"""Check if answer matches correct value."""
|
|
176
|
+
if isinstance(answer, str):
|
|
177
|
+
answer_lower = answer.strip().lower()
|
|
178
|
+
if answer_lower in ("true", "t", "yes", "y"):
|
|
179
|
+
answer = True
|
|
180
|
+
elif answer_lower in ("false", "f", "no", "n"):
|
|
181
|
+
answer = False
|
|
182
|
+
else:
|
|
183
|
+
return False, "Invalid input. Please enter True or False."
|
|
184
|
+
|
|
185
|
+
is_correct = answer == self.correct_answer
|
|
186
|
+
correct_text = "True" if self.correct_answer else "False"
|
|
187
|
+
if is_correct:
|
|
188
|
+
return True, f"Correct! The statement is {correct_text}."
|
|
189
|
+
return False, f"Incorrect. The statement is {correct_text}."
|
|
190
|
+
|
|
191
|
+
def to_dict(self) -> dict[str, Any]:
|
|
192
|
+
"""Convert to dictionary format."""
|
|
193
|
+
base = super().to_dict()
|
|
194
|
+
base.update({"correct_answer": self.correct_answer})
|
|
195
|
+
return base
|
|
196
|
+
|
|
197
|
+
@classmethod
|
|
198
|
+
def from_dict(cls, data: dict[str, Any]) -> TrueFalseQuestion:
|
|
199
|
+
"""Create instance from dictionary."""
|
|
200
|
+
return cls(
|
|
201
|
+
question_id=data["question_id"],
|
|
202
|
+
question_type=QuestionType.TRUE_FALSE,
|
|
203
|
+
question_text=data["question_text"],
|
|
204
|
+
points=data.get("points", 1.0),
|
|
205
|
+
correct_answer=data["correct_answer"],
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@dataclass
|
|
210
|
+
class EssayQuestion(Question):
|
|
211
|
+
"""Essay question requiring detailed answer."""
|
|
212
|
+
|
|
213
|
+
model_answer: str = ""
|
|
214
|
+
keywords: list[str] = field(
|
|
215
|
+
default_factory=list
|
|
216
|
+
) # Optional keywords for validation
|
|
217
|
+
|
|
218
|
+
def check_answer(self, answer: str) -> tuple[bool, str]:
|
|
219
|
+
"""Check if answer contains key points (soft validation)."""
|
|
220
|
+
answer_lower = answer.lower()
|
|
221
|
+
|
|
222
|
+
if self.keywords:
|
|
223
|
+
matched_keywords = [
|
|
224
|
+
kw for kw in self.keywords if kw.lower() in answer_lower
|
|
225
|
+
]
|
|
226
|
+
keyword_count = len(self.keywords)
|
|
227
|
+
matched_count = len(matched_keywords)
|
|
228
|
+
|
|
229
|
+
if matched_count >= keyword_count * 0.5: # At least 50% of keywords
|
|
230
|
+
return (
|
|
231
|
+
True,
|
|
232
|
+
f"Good answer! You covered {matched_count}/{keyword_count} key points: {', '.join(matched_keywords)}",
|
|
233
|
+
)
|
|
234
|
+
return (
|
|
235
|
+
False,
|
|
236
|
+
f"Your answer is incomplete. Consider including: {', '.join(self.keywords)}",
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
return True, "Answer recorded. Review model answer for comparison."
|
|
240
|
+
|
|
241
|
+
def to_dict(self) -> dict[str, Any]:
|
|
242
|
+
"""Convert to dictionary format."""
|
|
243
|
+
base = super().to_dict()
|
|
244
|
+
base.update({
|
|
245
|
+
"model_answer": self.model_answer,
|
|
246
|
+
"keywords": self.keywords,
|
|
247
|
+
})
|
|
248
|
+
return base
|
|
249
|
+
|
|
250
|
+
@classmethod
|
|
251
|
+
def from_dict(cls, data: dict[str, Any]) -> EssayQuestion:
|
|
252
|
+
"""Create instance from dictionary."""
|
|
253
|
+
return cls(
|
|
254
|
+
question_id=data["question_id"],
|
|
255
|
+
question_type=QuestionType.ESSAY,
|
|
256
|
+
question_text=data["question_text"],
|
|
257
|
+
points=data.get("points", 1.0),
|
|
258
|
+
model_answer=data.get("model_answer", ""),
|
|
259
|
+
keywords=data.get("keywords", []),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@dataclass
|
|
264
|
+
class QuizResult:
|
|
265
|
+
"""Result of answering a question."""
|
|
266
|
+
|
|
267
|
+
question: Question
|
|
268
|
+
user_answer: Any
|
|
269
|
+
is_correct: bool
|
|
270
|
+
explanation: str
|
|
271
|
+
|
|
272
|
+
def to_dict(self) -> dict[str, Any]:
|
|
273
|
+
"""Convert result to dictionary."""
|
|
274
|
+
return {
|
|
275
|
+
"question": self.question.to_dict(),
|
|
276
|
+
"user_answer": self.user_answer,
|
|
277
|
+
"is_correct": self.is_correct,
|
|
278
|
+
"explanation": self.explanation,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@dataclass
|
|
283
|
+
class QuizSession:
|
|
284
|
+
"""Manage a quiz session with multiple questions."""
|
|
285
|
+
|
|
286
|
+
questions: list[Question] = field(default_factory=list)
|
|
287
|
+
random_order: bool = False
|
|
288
|
+
wrong_answer_file: str = "wrong_answers.json"
|
|
289
|
+
results: list[QuizResult] = field(default_factory=list)
|
|
290
|
+
_current_index: int = 0
|
|
291
|
+
_shuffled_indices: list[int] = field(default_factory=list)
|
|
292
|
+
# Flag to control adaptive behavior (default False for regular QuizSession)
|
|
293
|
+
adaptive_mode: bool = False
|
|
294
|
+
|
|
295
|
+
@cached_property
|
|
296
|
+
def total_questions(self) -> int:
|
|
297
|
+
"""Get total number of questions."""
|
|
298
|
+
return len(self.questions)
|
|
299
|
+
|
|
300
|
+
@cached_property
|
|
301
|
+
def correct_count(self) -> int:
|
|
302
|
+
"""Get number of correct answers."""
|
|
303
|
+
return sum(1 for r in self.results if r.is_correct)
|
|
304
|
+
|
|
305
|
+
@cached_property
|
|
306
|
+
def wrong_count(self) -> int:
|
|
307
|
+
"""Get number of wrong answers."""
|
|
308
|
+
return sum(1 for r in self.results if not r.is_correct)
|
|
309
|
+
|
|
310
|
+
@cached_property
|
|
311
|
+
def total_points(self) -> float:
|
|
312
|
+
"""Get total possible points."""
|
|
313
|
+
return sum(q.points for q in self.questions)
|
|
314
|
+
|
|
315
|
+
@cached_property
|
|
316
|
+
def earned_points(self) -> float:
|
|
317
|
+
"""Get points earned."""
|
|
318
|
+
return sum(r.question.points for r in self.results if r.is_correct)
|
|
319
|
+
|
|
320
|
+
@cached_property
|
|
321
|
+
def accuracy(self) -> float:
|
|
322
|
+
"""Calculate accuracy percentage."""
|
|
323
|
+
if len(self.results) == 0:
|
|
324
|
+
return 0.0
|
|
325
|
+
return (self.correct_count / len(self.results)) * 100
|
|
326
|
+
|
|
327
|
+
def load_from_json(self, json_file: str | Path) -> None:
|
|
328
|
+
"""Load questions from JSON file."""
|
|
329
|
+
with open(json_file, encoding="utf-8") as f:
|
|
330
|
+
data = json.load(f)
|
|
331
|
+
|
|
332
|
+
self.questions = [Question.from_dict(q) for q in data.get("questions", [])]
|
|
333
|
+
self._initialize_order()
|
|
334
|
+
|
|
335
|
+
def _initialize_order(self) -> None:
|
|
336
|
+
"""Initialize question order based on settings."""
|
|
337
|
+
if self.random_order:
|
|
338
|
+
self._shuffled_indices = list(range(self.total_questions))
|
|
339
|
+
random.shuffle(self._shuffled_indices)
|
|
340
|
+
else:
|
|
341
|
+
self._shuffled_indices = list(range(self.total_questions))
|
|
342
|
+
|
|
343
|
+
def get_current_question(self) -> Question | None:
|
|
344
|
+
"""Get current question."""
|
|
345
|
+
if self._current_index >= len(self._shuffled_indices):
|
|
346
|
+
return None
|
|
347
|
+
idx = self._shuffled_indices[self._current_index]
|
|
348
|
+
return self.questions[idx]
|
|
349
|
+
|
|
350
|
+
def submit_answer(self, answer: Any) -> QuizResult | None:
|
|
351
|
+
"""Submit answer for current question."""
|
|
352
|
+
question = self.get_current_question()
|
|
353
|
+
if question is None:
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
is_correct, explanation = question.check_answer(answer)
|
|
357
|
+
result = QuizResult(
|
|
358
|
+
question=question,
|
|
359
|
+
user_answer=answer,
|
|
360
|
+
is_correct=is_correct,
|
|
361
|
+
explanation=explanation,
|
|
362
|
+
)
|
|
363
|
+
self.results.append(result)
|
|
364
|
+
self._current_index += 1
|
|
365
|
+
return result
|
|
366
|
+
|
|
367
|
+
def is_finished(self) -> bool:
|
|
368
|
+
"""Check if quiz is finished."""
|
|
369
|
+
return self._current_index >= len(self._shuffled_indices)
|
|
370
|
+
|
|
371
|
+
def get_wrong_answers(self) -> list[QuizResult]:
|
|
372
|
+
"""Get all wrong answers."""
|
|
373
|
+
return [r for r in self.results if not r.is_correct]
|
|
374
|
+
|
|
375
|
+
def save_wrong_answers(self, file_path: str | Path | None = None) -> None:
|
|
376
|
+
"""Save wrong answers to JSON file."""
|
|
377
|
+
wrong_answers = self.get_wrong_answers()
|
|
378
|
+
if not wrong_answers:
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
output_path = Path(file_path) if file_path else Path(self.wrong_answer_file)
|
|
382
|
+
data = {"wrong_answers": [r.to_dict() for r in wrong_answers]}
|
|
383
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
384
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
385
|
+
|
|
386
|
+
def get_summary(self) -> dict[str, Any]:
|
|
387
|
+
"""Get quiz session summary."""
|
|
388
|
+
return {
|
|
389
|
+
"total_questions": self.total_questions,
|
|
390
|
+
"answered": len(self.results),
|
|
391
|
+
"correct": self.correct_count,
|
|
392
|
+
"wrong": self.wrong_count,
|
|
393
|
+
"total_points": self.total_points,
|
|
394
|
+
"earned_points": self.earned_points,
|
|
395
|
+
"accuracy": self.accuracy,
|
|
396
|
+
"is_finished": self.is_finished(),
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
def reset(self, random_order: bool | None = None) -> None:
|
|
400
|
+
"""Reset quiz session."""
|
|
401
|
+
self._current_index = 0
|
|
402
|
+
self.results = []
|
|
403
|
+
if random_order is not None:
|
|
404
|
+
self.random_order = random_order
|
|
405
|
+
self._initialize_order()
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def create_sample_quiz_data(output_file: str | Path) -> None:
|
|
409
|
+
"""Create sample quiz data file with all question types."""
|
|
410
|
+
questions = [
|
|
411
|
+
{
|
|
412
|
+
"question_id": "mc1",
|
|
413
|
+
"question_type": "multiple_choice",
|
|
414
|
+
"question_text": "What is the capital of France?",
|
|
415
|
+
"options": ["London", "Paris", "Berlin", "Rome"],
|
|
416
|
+
"correct_answer": 1,
|
|
417
|
+
"allow_multiple": False,
|
|
418
|
+
"points": 1.0,
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
"question_id": "mc2",
|
|
422
|
+
"question_type": "multiple_choice",
|
|
423
|
+
"question_text": "Which of the following are programming languages? (Select all that apply)",
|
|
424
|
+
"options": ["Python", "HTML", "Java", "CSS", "JavaScript"],
|
|
425
|
+
"correct_answer": [0, 2, 4],
|
|
426
|
+
"allow_multiple": True,
|
|
427
|
+
"points": 2.0,
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
"question_id": "fb1",
|
|
431
|
+
"question_type": "fill_blank",
|
|
432
|
+
"question_text": "The planet closest to the Sun is _____.",
|
|
433
|
+
"correct_answers": ["Mercury"],
|
|
434
|
+
"case_sensitive": False,
|
|
435
|
+
"points": 1.0,
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
"question_id": "fb2",
|
|
439
|
+
"question_type": "fill_blank",
|
|
440
|
+
"question_text": "The largest ocean on Earth is the _____ Ocean.",
|
|
441
|
+
"correct_answers": ["Pacific", "pacific", "PACIFIC"],
|
|
442
|
+
"case_sensitive": False,
|
|
443
|
+
"points": 1.0,
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
"question_id": "tf1",
|
|
447
|
+
"question_type": "true_false",
|
|
448
|
+
"question_text": "Python is a compiled language.",
|
|
449
|
+
"correct_answer": False,
|
|
450
|
+
"points": 1.0,
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
"question_id": "tf2",
|
|
454
|
+
"question_type": "true_false",
|
|
455
|
+
"question_text": "The Earth revolves around the Sun.",
|
|
456
|
+
"correct_answer": True,
|
|
457
|
+
"points": 1.0,
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
"question_id": "es1",
|
|
461
|
+
"question_type": "essay",
|
|
462
|
+
"question_text": "Explain the concept of object-oriented programming.",
|
|
463
|
+
"model_answer": "Object-oriented programming (OOP) is a programming paradigm based on the concept of objects, which can contain data and code. The four main principles are encapsulation, abstraction, inheritance, and polymorphism.",
|
|
464
|
+
"keywords": [
|
|
465
|
+
"objects",
|
|
466
|
+
"classes",
|
|
467
|
+
"inheritance",
|
|
468
|
+
"encapsulation",
|
|
469
|
+
"polymorphism",
|
|
470
|
+
"abstraction",
|
|
471
|
+
],
|
|
472
|
+
"points": 5.0,
|
|
473
|
+
},
|
|
474
|
+
]
|
|
475
|
+
|
|
476
|
+
data = {"title": "General Knowledge Quiz", "questions": questions}
|
|
477
|
+
output_path = Path(output_file)
|
|
478
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
479
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
480
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def main():
|
|
484
|
+
"""Main entry point for quizbase CLI."""
|
|
485
|
+
import argparse
|
|
486
|
+
|
|
487
|
+
parser = argparse.ArgumentParser(description="Universal quiz system")
|
|
488
|
+
parser.add_argument(
|
|
489
|
+
"--create-sample", action="store_true", help="Create sample quiz data file"
|
|
490
|
+
)
|
|
491
|
+
parser.add_argument("--file", type=str, help="Quiz JSON file to load")
|
|
492
|
+
parser.add_argument("--random", action="store_true", help="Shuffle questions")
|
|
493
|
+
parser.add_argument(
|
|
494
|
+
"--adaptive",
|
|
495
|
+
action="store_true",
|
|
496
|
+
help="Use adaptive question selection based on performance",
|
|
497
|
+
)
|
|
498
|
+
parser.add_argument(
|
|
499
|
+
"--history-file",
|
|
500
|
+
type=str,
|
|
501
|
+
help="File to load/save user performance history",
|
|
502
|
+
default="user_performance.json",
|
|
503
|
+
)
|
|
504
|
+
parser.add_argument("--wrong", type=str, help="Save wrong answers to file")
|
|
505
|
+
args = parser.parse_args()
|
|
506
|
+
|
|
507
|
+
if args.create_sample:
|
|
508
|
+
sample_file = Path("sample_quiz.json")
|
|
509
|
+
create_sample_quiz_data(sample_file)
|
|
510
|
+
print(f"Sample quiz data created: {sample_file}")
|
|
511
|
+
return
|
|
512
|
+
|
|
513
|
+
if args.file:
|
|
514
|
+
# Use adaptive session if adaptive flag is set
|
|
515
|
+
if args.adaptive:
|
|
516
|
+
session = AdaptiveQuizSession(random_order=args.random)
|
|
517
|
+
session.history_file = args.history_file
|
|
518
|
+
session.load_performance_history()
|
|
519
|
+
else:
|
|
520
|
+
session = QuizSession(random_order=args.random)
|
|
521
|
+
|
|
522
|
+
if args.wrong:
|
|
523
|
+
session.wrong_answer_file = args.wrong
|
|
524
|
+
session.load_from_json(args.file)
|
|
525
|
+
|
|
526
|
+
print(
|
|
527
|
+
f"\n=== Quiz Started ({'Adaptive' if args.adaptive else 'Random' if args.random else 'Sequential'} Order) ==="
|
|
528
|
+
)
|
|
529
|
+
print(f"Total Questions: {session.total_questions}\n")
|
|
530
|
+
|
|
531
|
+
while not session.is_finished():
|
|
532
|
+
question = session.get_current_question()
|
|
533
|
+
if question is None:
|
|
534
|
+
break
|
|
535
|
+
|
|
536
|
+
print(f"\nQuestion {len(session.results) + 1}/{session.total_questions}")
|
|
537
|
+
print(f"[{question.question_type.value}] {question.question_text}")
|
|
538
|
+
print(f"Points: {question.points}")
|
|
539
|
+
|
|
540
|
+
if isinstance(question, MultipleChoiceQuestion):
|
|
541
|
+
for i, option in enumerate(question.options):
|
|
542
|
+
print(f" {i}. {option}")
|
|
543
|
+
|
|
544
|
+
answer = input("\nYour answer: ")
|
|
545
|
+
|
|
546
|
+
try:
|
|
547
|
+
if isinstance(question, MultipleChoiceQuestion):
|
|
548
|
+
if question.allow_multiple:
|
|
549
|
+
answer = [int(x.strip()) for x in answer.split(",")]
|
|
550
|
+
else:
|
|
551
|
+
answer = int(answer)
|
|
552
|
+
elif isinstance(question, TrueFalseQuestion):
|
|
553
|
+
answer = answer.strip().lower() in ("true", "t", "yes", "y")
|
|
554
|
+
elif isinstance(question, EssayQuestion):
|
|
555
|
+
answer = answer
|
|
556
|
+
else:
|
|
557
|
+
answer = answer
|
|
558
|
+
|
|
559
|
+
result = session.submit_answer(answer)
|
|
560
|
+
if result:
|
|
561
|
+
print(f"\n{'✓' if result.is_correct else '✗'} {result.explanation}")
|
|
562
|
+
|
|
563
|
+
except (ValueError, IndexError) as e:
|
|
564
|
+
print(f"\nInvalid input: {e}")
|
|
565
|
+
continue
|
|
566
|
+
|
|
567
|
+
# Save performance history if using adaptive session
|
|
568
|
+
if args.adaptive and hasattr(session, "save_performance_history"):
|
|
569
|
+
session.save_performance_history()
|
|
570
|
+
|
|
571
|
+
session.save_wrong_answers()
|
|
572
|
+
summary = session.get_summary()
|
|
573
|
+
print("\n=== Quiz Summary ===")
|
|
574
|
+
print(f"Answered: {summary['answered']}/{summary['total_questions']}")
|
|
575
|
+
print(f"Correct: {summary['correct']} | Wrong: {summary['wrong']}")
|
|
576
|
+
print(f"Points: {summary['earned_points']:.1f}/{summary['total_points']:.1f}")
|
|
577
|
+
print(f"Accuracy: {summary['accuracy']:.1f}%")
|
|
578
|
+
|
|
579
|
+
# Print performance summary if using adaptive session
|
|
580
|
+
if args.adaptive and hasattr(session, "get_progress_summary"):
|
|
581
|
+
print("\n=== Performance Summary ===")
|
|
582
|
+
perf_summary = session.get_progress_summary()
|
|
583
|
+
for qid, stats in list(perf_summary["performance_by_question"].items())[
|
|
584
|
+
:5
|
|
585
|
+
]: # Show first 5
|
|
586
|
+
print(
|
|
587
|
+
f"{qid}: {stats['correct_count']} correct, {stats['incorrect_count']} incorrect, {stats['success_rate']:.2f} success rate"
|
|
588
|
+
)
|
|
589
|
+
if len(perf_summary["performance_by_question"]) > 5:
|
|
590
|
+
print(
|
|
591
|
+
f"... and {len(perf_summary['performance_by_question']) - 5} more questions"
|
|
592
|
+
)
|
|
593
|
+
else:
|
|
594
|
+
print(
|
|
595
|
+
"Usage: quizbase --create-sample | --file <quiz.json> [--random] [--adaptive] [--history-file <file>] [--wrong <file>]"
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
@dataclass
|
|
600
|
+
class QuestionPerformance:
|
|
601
|
+
"""Track performance statistics for a single question."""
|
|
602
|
+
|
|
603
|
+
question_id: str
|
|
604
|
+
correct_count: int = 0
|
|
605
|
+
incorrect_count: int = 0
|
|
606
|
+
total_attempts: int = 0
|
|
607
|
+
|
|
608
|
+
def get_success_rate(self) -> float:
|
|
609
|
+
"""Get success rate (0.0 to 1.0)."""
|
|
610
|
+
if self.total_attempts == 0:
|
|
611
|
+
return 0.5 # Default to 50% if no attempts
|
|
612
|
+
return self.correct_count / self.total_attempts
|
|
613
|
+
|
|
614
|
+
def get_weight(self) -> float:
|
|
615
|
+
"""
|
|
616
|
+
Calculate weight for adaptive selection.
|
|
617
|
+
Lower weight for well-performed questions, higher weight for poorly performed questions.
|
|
618
|
+
"""
|
|
619
|
+
success_rate = self.get_success_rate()
|
|
620
|
+
# Invert the success rate so that poorly answered questions have higher weights
|
|
621
|
+
# Add a small constant to ensure even perfectly answered questions have some chance of appearing
|
|
622
|
+
return (1.0 - success_rate) + 0.1
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
@dataclass
|
|
626
|
+
class AdaptiveQuizSession(QuizSession):
|
|
627
|
+
"""Quiz session with adaptive question selection based on user performance."""
|
|
628
|
+
|
|
629
|
+
# Track user performance for each question
|
|
630
|
+
performance_history: dict[str, QuestionPerformance] = field(default_factory=dict)
|
|
631
|
+
# File to save/load performance history
|
|
632
|
+
history_file: str = "user_performance.json"
|
|
633
|
+
# Flag to control adaptive behavior
|
|
634
|
+
adaptive_mode: bool = True
|
|
635
|
+
|
|
636
|
+
def __post_init__(self):
|
|
637
|
+
"""Initialize after dataclass fields are set."""
|
|
638
|
+
if not self.performance_history:
|
|
639
|
+
self.performance_history = {}
|
|
640
|
+
|
|
641
|
+
def load_performance_history(self, file_path: str | Path | None = None) -> None:
|
|
642
|
+
"""Load user performance history from file."""
|
|
643
|
+
path = Path(file_path) if file_path else Path(self.history_file)
|
|
644
|
+
|
|
645
|
+
if path.exists():
|
|
646
|
+
try:
|
|
647
|
+
with open(path, encoding="utf-8") as f:
|
|
648
|
+
data = json.load(f)
|
|
649
|
+
|
|
650
|
+
for qid, perf_data in data.items():
|
|
651
|
+
self.performance_history[qid] = QuestionPerformance(
|
|
652
|
+
question_id=qid,
|
|
653
|
+
correct_count=perf_data.get("correct_count", 0),
|
|
654
|
+
incorrect_count=perf_data.get("incorrect_count", 0),
|
|
655
|
+
total_attempts=perf_data.get("total_attempts", 0),
|
|
656
|
+
)
|
|
657
|
+
except (json.JSONDecodeError, KeyError) as e:
|
|
658
|
+
print(f"Could not load performance history: {e}")
|
|
659
|
+
self.performance_history = {}
|
|
660
|
+
else:
|
|
661
|
+
self.performance_history = {}
|
|
662
|
+
|
|
663
|
+
def save_performance_history(self, file_path: str | Path | None = None) -> None:
|
|
664
|
+
"""Save user performance history to file."""
|
|
665
|
+
path = Path(file_path) if file_path else Path(self.history_file)
|
|
666
|
+
|
|
667
|
+
data = {}
|
|
668
|
+
for qid, perf in self.performance_history.items():
|
|
669
|
+
data[qid] = {
|
|
670
|
+
"correct_count": perf.correct_count,
|
|
671
|
+
"incorrect_count": perf.incorrect_count,
|
|
672
|
+
"total_attempts": perf.total_attempts,
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
676
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
677
|
+
|
|
678
|
+
def update_performance(self, question: Question, is_correct: bool) -> None:
|
|
679
|
+
"""Update performance statistics for a question."""
|
|
680
|
+
qid = question.question_id
|
|
681
|
+
|
|
682
|
+
if qid not in self.performance_history:
|
|
683
|
+
self.performance_history[qid] = QuestionPerformance(question_id=qid)
|
|
684
|
+
|
|
685
|
+
perf = self.performance_history[qid]
|
|
686
|
+
perf.total_attempts += 1
|
|
687
|
+
|
|
688
|
+
if is_correct:
|
|
689
|
+
perf.correct_count += 1
|
|
690
|
+
else:
|
|
691
|
+
perf.incorrect_count += 1
|
|
692
|
+
|
|
693
|
+
def get_adaptive_question_indices(self) -> list[int]:
|
|
694
|
+
"""Get question indices with adaptive weighting based on performance."""
|
|
695
|
+
if not self.questions:
|
|
696
|
+
return []
|
|
697
|
+
|
|
698
|
+
# If we don't have performance data for all questions, use uniform distribution initially
|
|
699
|
+
all_have_data = all(
|
|
700
|
+
q.question_id in self.performance_history for q in self.questions
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
if not all_have_data:
|
|
704
|
+
# Return shuffled indices for initial uniform exposure
|
|
705
|
+
indices = list(range(len(self.questions)))
|
|
706
|
+
random.shuffle(indices)
|
|
707
|
+
return indices
|
|
708
|
+
|
|
709
|
+
# Calculate weights for each question based on performance
|
|
710
|
+
weights = []
|
|
711
|
+
for _, question in enumerate(self.questions):
|
|
712
|
+
perf = self.performance_history.get(question.question_id)
|
|
713
|
+
weight = perf.get_weight() if perf else 0.5
|
|
714
|
+
weights.append(weight)
|
|
715
|
+
|
|
716
|
+
# Normalize weights to probabilities
|
|
717
|
+
total_weight = sum(weights)
|
|
718
|
+
if total_weight == 0:
|
|
719
|
+
# If all weights are zero, use uniform distribution
|
|
720
|
+
probabilities = [1.0 / len(weights)] * len(weights)
|
|
721
|
+
else:
|
|
722
|
+
probabilities = [w / total_weight for w in weights]
|
|
723
|
+
|
|
724
|
+
# Create a weighted shuffle of indices
|
|
725
|
+
indices = list(range(len(self.questions)))
|
|
726
|
+
adaptive_indices = []
|
|
727
|
+
remaining_indices = indices[:]
|
|
728
|
+
remaining_probs = probabilities[:]
|
|
729
|
+
|
|
730
|
+
# Use weighted selection without replacement to create order
|
|
731
|
+
while remaining_indices:
|
|
732
|
+
# Normalize remaining probabilities
|
|
733
|
+
total_remaining_prob = sum(remaining_probs)
|
|
734
|
+
if total_remaining_prob == 0:
|
|
735
|
+
# Fallback to random selection if all probs are 0
|
|
736
|
+
selected_idx = random.randint(0, len(remaining_indices) - 1)
|
|
737
|
+
else:
|
|
738
|
+
norm_probs = [p / total_remaining_prob for p in remaining_probs]
|
|
739
|
+
selected_idx = random.choices(
|
|
740
|
+
range(len(remaining_indices)), weights=norm_probs
|
|
741
|
+
)[0]
|
|
742
|
+
|
|
743
|
+
# Add selected index to result and remove from remaining
|
|
744
|
+
adaptive_indices.append(remaining_indices.pop(selected_idx))
|
|
745
|
+
remaining_probs.pop(selected_idx)
|
|
746
|
+
|
|
747
|
+
return adaptive_indices
|
|
748
|
+
|
|
749
|
+
def submit_answer(self, answer: Any) -> QuizResult | None:
|
|
750
|
+
"""Submit answer and update performance tracking."""
|
|
751
|
+
question = self.get_current_question()
|
|
752
|
+
if question is None:
|
|
753
|
+
return None
|
|
754
|
+
|
|
755
|
+
is_correct, explanation = question.check_answer(answer)
|
|
756
|
+
result = QuizResult(
|
|
757
|
+
question=question,
|
|
758
|
+
user_answer=answer,
|
|
759
|
+
is_correct=is_correct,
|
|
760
|
+
explanation=explanation,
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
# Update performance history before appending to results
|
|
764
|
+
self.update_performance(question, is_correct)
|
|
765
|
+
|
|
766
|
+
self.results.append(result)
|
|
767
|
+
self._current_index += 1
|
|
768
|
+
return result
|
|
769
|
+
|
|
770
|
+
def reset(self, random_order: bool | None = None) -> None:
|
|
771
|
+
"""Reset quiz session while preserving performance history."""
|
|
772
|
+
self._current_index = 0
|
|
773
|
+
self.results = []
|
|
774
|
+
|
|
775
|
+
if random_order is not None:
|
|
776
|
+
self.random_order = random_order
|
|
777
|
+
|
|
778
|
+
# Re-initialize order based on adaptive algorithm if enabled
|
|
779
|
+
self._initialize_order()
|
|
780
|
+
|
|
781
|
+
def _initialize_order(self) -> None:
|
|
782
|
+
"""Initialize question order based on settings and performance."""
|
|
783
|
+
if self.adaptive_mode and self.random_order:
|
|
784
|
+
# Use adaptive ordering if we have performance data, otherwise random
|
|
785
|
+
self._shuffled_indices = self.get_adaptive_question_indices()
|
|
786
|
+
else:
|
|
787
|
+
# Use regular random or sequential ordering
|
|
788
|
+
if self.random_order:
|
|
789
|
+
self._shuffled_indices = list(range(self.total_questions))
|
|
790
|
+
random.shuffle(self._shuffled_indices)
|
|
791
|
+
else:
|
|
792
|
+
self._shuffled_indices = list(range(self.total_questions))
|
|
793
|
+
|
|
794
|
+
def get_progress_summary(self) -> dict[str, Any]:
|
|
795
|
+
"""Get detailed progress summary for all questions."""
|
|
796
|
+
summary = {
|
|
797
|
+
"total_questions": self.total_questions,
|
|
798
|
+
"answered_questions": len(self.results),
|
|
799
|
+
"performance_by_question": {},
|
|
800
|
+
"overall_accuracy": self.accuracy,
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
for question in self.questions:
|
|
804
|
+
perf = self.performance_history.get(question.question_id)
|
|
805
|
+
if perf:
|
|
806
|
+
summary["performance_by_question"][question.question_id] = {
|
|
807
|
+
"question_text": question.question_text,
|
|
808
|
+
"correct_count": perf.correct_count,
|
|
809
|
+
"incorrect_count": perf.incorrect_count,
|
|
810
|
+
"total_attempts": perf.total_attempts,
|
|
811
|
+
"success_rate": perf.get_success_rate(),
|
|
812
|
+
"weight_for_selection": perf.get_weight(),
|
|
813
|
+
}
|
|
814
|
+
else:
|
|
815
|
+
summary["performance_by_question"][question.question_id] = {
|
|
816
|
+
"question_text": question.question_text,
|
|
817
|
+
"correct_count": 0,
|
|
818
|
+
"incorrect_count": 0,
|
|
819
|
+
"total_attempts": 0,
|
|
820
|
+
"success_rate": 0.0,
|
|
821
|
+
"weight_for_selection": 0.5,
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
return summary
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
if __name__ == "__main__":
|
|
828
|
+
main()
|