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.
Files changed (55) hide show
  1. {pysfi-0.1.7.dist-info → pysfi-0.1.11.dist-info}/METADATA +11 -9
  2. pysfi-0.1.11.dist-info/RECORD +60 -0
  3. pysfi-0.1.11.dist-info/entry_points.txt +28 -0
  4. sfi/__init__.py +1 -1
  5. sfi/alarmclock/alarmclock.py +40 -40
  6. sfi/bumpversion/__init__.py +1 -1
  7. sfi/cleanbuild/cleanbuild.py +155 -0
  8. sfi/condasetup/condasetup.py +116 -0
  9. sfi/docscan/__init__.py +1 -1
  10. sfi/docscan/docscan.py +407 -103
  11. sfi/docscan/docscan_gui.py +1282 -596
  12. sfi/docscan/lang/eng.py +152 -0
  13. sfi/docscan/lang/zhcn.py +170 -0
  14. sfi/filedate/filedate.py +185 -112
  15. sfi/gittool/__init__.py +2 -0
  16. sfi/gittool/gittool.py +401 -0
  17. sfi/llmclient/llmclient.py +592 -0
  18. sfi/llmquantize/llmquantize.py +480 -0
  19. sfi/llmserver/llmserver.py +335 -0
  20. sfi/makepython/makepython.py +31 -30
  21. sfi/pdfsplit/pdfsplit.py +173 -173
  22. sfi/pyarchive/pyarchive.py +418 -0
  23. sfi/pyembedinstall/pyembedinstall.py +629 -0
  24. sfi/pylibpack/__init__.py +0 -0
  25. sfi/pylibpack/pylibpack.py +1457 -0
  26. sfi/pylibpack/rules/numpy.json +22 -0
  27. sfi/pylibpack/rules/pymupdf.json +10 -0
  28. sfi/pylibpack/rules/pyqt5.json +19 -0
  29. sfi/pylibpack/rules/pyside2.json +23 -0
  30. sfi/pylibpack/rules/scipy.json +23 -0
  31. sfi/pylibpack/rules/shiboken2.json +24 -0
  32. sfi/pyloadergen/pyloadergen.py +512 -227
  33. sfi/pypack/__init__.py +0 -0
  34. sfi/pypack/pypack.py +1142 -0
  35. sfi/pyprojectparse/__init__.py +0 -0
  36. sfi/pyprojectparse/pyprojectparse.py +500 -0
  37. sfi/pysourcepack/pysourcepack.py +308 -0
  38. sfi/quizbase/__init__.py +0 -0
  39. sfi/quizbase/quizbase.py +828 -0
  40. sfi/quizbase/quizbase_gui.py +987 -0
  41. sfi/regexvalidate/__init__.py +0 -0
  42. sfi/regexvalidate/regex_help.html +284 -0
  43. sfi/regexvalidate/regexvalidate.py +468 -0
  44. sfi/taskkill/taskkill.py +0 -2
  45. sfi/workflowengine/__init__.py +0 -0
  46. sfi/workflowengine/workflowengine.py +444 -0
  47. pysfi-0.1.7.dist-info/RECORD +0 -31
  48. pysfi-0.1.7.dist-info/entry_points.txt +0 -15
  49. sfi/embedinstall/embedinstall.py +0 -418
  50. sfi/projectparse/projectparse.py +0 -152
  51. sfi/pypacker/fspacker.py +0 -91
  52. {pysfi-0.1.7.dist-info → pysfi-0.1.11.dist-info}/WHEEL +0 -0
  53. /sfi/{embedinstall → docscan/lang}/__init__.py +0 -0
  54. /sfi/{projectparse → llmquantize}/__init__.py +0 -0
  55. /sfi/{pypacker → pyembedinstall}/__init__.py +0 -0
@@ -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()