gemmate-srs 0.1.0__tar.gz

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 (33) hide show
  1. gemmate_srs-0.1.0/PKG-INFO +74 -0
  2. gemmate_srs-0.1.0/README.md +55 -0
  3. gemmate_srs-0.1.0/gemmate_srs/__init__.py +1 -0
  4. gemmate_srs-0.1.0/gemmate_srs/engines/__init__.py +13 -0
  5. gemmate_srs-0.1.0/gemmate_srs/engines/cards.py +173 -0
  6. gemmate_srs-0.1.0/gemmate_srs/engines/mindmap.py +140 -0
  7. gemmate_srs-0.1.0/gemmate_srs/engines/quiz.py +161 -0
  8. gemmate_srs-0.1.0/gemmate_srs/engines/solver.py +140 -0
  9. gemmate_srs-0.1.0/gemmate_srs/json_repair.py +150 -0
  10. gemmate_srs-0.1.0/gemmate_srs/models.py +73 -0
  11. gemmate_srs-0.1.0/gemmate_srs/plugin.py +378 -0
  12. gemmate_srs-0.1.0/gemmate_srs/protocol.py +199 -0
  13. gemmate_srs-0.1.0/gemmate_srs/sampling.py +195 -0
  14. gemmate_srs-0.1.0/gemmate_srs/sm2.py +65 -0
  15. gemmate_srs-0.1.0/gemmate_srs/store.py +156 -0
  16. gemmate_srs-0.1.0/gemmate_srs/tools.py +420 -0
  17. gemmate_srs-0.1.0/gemmate_srs.egg-info/PKG-INFO +74 -0
  18. gemmate_srs-0.1.0/gemmate_srs.egg-info/SOURCES.txt +31 -0
  19. gemmate_srs-0.1.0/gemmate_srs.egg-info/dependency_links.txt +1 -0
  20. gemmate_srs-0.1.0/gemmate_srs.egg-info/entry_points.txt +2 -0
  21. gemmate_srs-0.1.0/gemmate_srs.egg-info/requires.txt +4 -0
  22. gemmate_srs-0.1.0/gemmate_srs.egg-info/top_level.txt +1 -0
  23. gemmate_srs-0.1.0/pyproject.toml +42 -0
  24. gemmate_srs-0.1.0/setup.cfg +4 -0
  25. gemmate_srs-0.1.0/tests/test_engines.py +374 -0
  26. gemmate_srs-0.1.0/tests/test_json_repair.py +86 -0
  27. gemmate_srs-0.1.0/tests/test_plugin.py +138 -0
  28. gemmate_srs-0.1.0/tests/test_protocol.py +99 -0
  29. gemmate_srs-0.1.0/tests/test_sampling.py +183 -0
  30. gemmate_srs-0.1.0/tests/test_sm2.py +86 -0
  31. gemmate_srs-0.1.0/tests/test_store.py +191 -0
  32. gemmate_srs-0.1.0/tests/test_tools.py +178 -0
  33. gemmate_srs-0.1.0/tests/test_utf8_stdio.py +118 -0
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: gemmate-srs
3
+ Version: 0.1.0
4
+ Summary: Spaced-repetition study engine for the GemMate Anna App: SM-2 scheduling, card decks, quiz generation, mind maps and step-by-step math solving.
5
+ Author: gemmate-dev
6
+ License: MIT
7
+ Project-URL: Homepage, https://anna.partners
8
+ Project-URL: Source, https://github.com/gemmate-dev/gemmate-anna-app
9
+ Keywords: spaced-repetition,supermemo,sm-2,flashcards,executa,anna
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Topic :: Education
14
+ Requires-Python: >=3.11
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: pydantic>=2.5.0
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest>=7.4.0; extra == "test"
19
+
20
+ # gemmate-srs
21
+
22
+ The Executa (Anna plugin) behind the **GemMate** Anna App. A single long-running
23
+ process that speaks JSON-RPC 2.0 over stdio and exposes the study engine the
24
+ app's UI calls into.
25
+
26
+ ## Tools
27
+
28
+ | Method | What it does |
29
+ |---|---|
30
+ | `deck.create` / `deck.list` / `deck.delete` | Deck management |
31
+ | `card.add` / `card.list` / `card.update` / `card.delete` | Card management |
32
+ | `study.session` | Open a review session for a deck (due cards first) |
33
+ | `study.grade` | Grade recall 0–5 and compute the next interval via SM-2 |
34
+ | `study.stats` | Deck-level counters |
35
+ | `cards.generate` | Generate flashcards for a topic, persist them to a deck |
36
+ | `quiz.generate` | Generate four-option multiple-choice questions |
37
+ | `mindmap.generate` | Extract a hierarchical concept tree from notes |
38
+ | `solve.steps` | Step-by-step derivation with a final answer and check |
39
+
40
+ Scheduling, deck and card state live in Anna Persistent Storage (APS), never in
41
+ the iframe, so progress survives window close, reload and device changes.
42
+
43
+ ## SM-2 scheduling
44
+
45
+ Implements the standard SuperMemo-2 update:
46
+
47
+ - `quality >= 3` → first pass sets interval 1 day, second sets 6 days, then
48
+ `interval = round(interval * EF)` with `round` matching Dart's
49
+ `double.round()` (half away from zero) — not Python's banker's rounding.
50
+ - `quality < 3` → repetitions reset to 0, interval back to 1 day.
51
+ - `EF' = max(1.3, EF + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02)))`, applied on
52
+ every grade including failures, which is what the original GemMate Dart
53
+ implementation does and what existing review history expects.
54
+
55
+ ## LLM use
56
+
57
+ The four generation tools call the host LLM through Anna sampling
58
+ (`sampling/createMessage`). They are additive: review, deck and card management
59
+ never touch the LLM, so a user who declines the sampling grant still gets a
60
+ fully working spaced-repetition app.
61
+
62
+ ## Development
63
+
64
+ ```bash
65
+ uv sync --extra test
66
+ uv run pytest -q
67
+ uv run gemmate-srs # the plugin loop; reads JSON-RPC frames on stdin
68
+ ```
69
+
70
+ ## Distribution
71
+
72
+ Declared in `executa.json` as `distribution.type = "uv"`. The platform installs
73
+ it with `uv tool install gemmate-srs` and launches the `gemmate-srs` console
74
+ script.
@@ -0,0 +1,55 @@
1
+ # gemmate-srs
2
+
3
+ The Executa (Anna plugin) behind the **GemMate** Anna App. A single long-running
4
+ process that speaks JSON-RPC 2.0 over stdio and exposes the study engine the
5
+ app's UI calls into.
6
+
7
+ ## Tools
8
+
9
+ | Method | What it does |
10
+ |---|---|
11
+ | `deck.create` / `deck.list` / `deck.delete` | Deck management |
12
+ | `card.add` / `card.list` / `card.update` / `card.delete` | Card management |
13
+ | `study.session` | Open a review session for a deck (due cards first) |
14
+ | `study.grade` | Grade recall 0–5 and compute the next interval via SM-2 |
15
+ | `study.stats` | Deck-level counters |
16
+ | `cards.generate` | Generate flashcards for a topic, persist them to a deck |
17
+ | `quiz.generate` | Generate four-option multiple-choice questions |
18
+ | `mindmap.generate` | Extract a hierarchical concept tree from notes |
19
+ | `solve.steps` | Step-by-step derivation with a final answer and check |
20
+
21
+ Scheduling, deck and card state live in Anna Persistent Storage (APS), never in
22
+ the iframe, so progress survives window close, reload and device changes.
23
+
24
+ ## SM-2 scheduling
25
+
26
+ Implements the standard SuperMemo-2 update:
27
+
28
+ - `quality >= 3` → first pass sets interval 1 day, second sets 6 days, then
29
+ `interval = round(interval * EF)` with `round` matching Dart's
30
+ `double.round()` (half away from zero) — not Python's banker's rounding.
31
+ - `quality < 3` → repetitions reset to 0, interval back to 1 day.
32
+ - `EF' = max(1.3, EF + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02)))`, applied on
33
+ every grade including failures, which is what the original GemMate Dart
34
+ implementation does and what existing review history expects.
35
+
36
+ ## LLM use
37
+
38
+ The four generation tools call the host LLM through Anna sampling
39
+ (`sampling/createMessage`). They are additive: review, deck and card management
40
+ never touch the LLM, so a user who declines the sampling grant still gets a
41
+ fully working spaced-repetition app.
42
+
43
+ ## Development
44
+
45
+ ```bash
46
+ uv sync --extra test
47
+ uv run pytest -q
48
+ uv run gemmate-srs # the plugin loop; reads JSON-RPC frames on stdin
49
+ ```
50
+
51
+ ## Distribution
52
+
53
+ Declared in `executa.json` as `distribution.type = "uv"`. The platform installs
54
+ it with `uv tool install gemmate-srs` and launches the `gemmate-srs` console
55
+ script.
@@ -0,0 +1 @@
1
+ """GemMate SRS engine package."""
@@ -0,0 +1,13 @@
1
+ """Engines package for GemMate SRS."""
2
+
3
+ from .cards import generate_cards
4
+ from .mindmap import build_mindmap
5
+ from .quiz import generate_quiz
6
+ from .solver import solve_steps
7
+
8
+ __all__ = [
9
+ "generate_cards",
10
+ "generate_quiz",
11
+ "build_mindmap",
12
+ "solve_steps",
13
+ ]
@@ -0,0 +1,173 @@
1
+ import sys
2
+ import uuid
3
+ from datetime import datetime, timezone
4
+ from typing import Any
5
+
6
+ from ..json_repair import robust_json_parse
7
+ from ..models import Card, Deck, SM2State
8
+ from ..sampling import sample
9
+ from ..store import APSStore
10
+
11
+ SCHEMA_CARDS = {
12
+ "type": "object",
13
+ "properties": {
14
+ "cards": {
15
+ "type": "array",
16
+ "items": {
17
+ "type": "object",
18
+ "properties": {
19
+ "front": {"type": "string"},
20
+ "back": {"type": "string"},
21
+ },
22
+ "required": ["front", "back"],
23
+ "additionalProperties": False,
24
+ },
25
+ }
26
+ },
27
+ "required": ["cards"],
28
+ "additionalProperties": False,
29
+ }
30
+
31
+
32
+ def generate_cards(
33
+ topic: str,
34
+ count: int = 5,
35
+ deck_id: str | None = None,
36
+ invoke_id: str = "",
37
+ store: APSStore | None = None,
38
+ ) -> dict[str, Any]:
39
+ topic = (topic or "").strip()
40
+ if not topic:
41
+ raise ValueError("topic must not be empty")
42
+
43
+ count = max(1, min(int(count), 30))
44
+
45
+ prompt = (
46
+ f"Create exactly {count} high-quality flashcards for learning the following topic:\n"
47
+ f"{topic}\n\n"
48
+ "Requirements:\n"
49
+ "1. Each card must test one specific concept, definition, formula, or key fact.\n"
50
+ "2. 'front' is the prompt or question (concise and clear).\n"
51
+ "3. 'back' is the answer or explanation (accurate and educational).\n"
52
+ "4. Return ONLY a JSON object with a 'cards' array of {front, back} objects."
53
+ )
54
+
55
+ system_prompt = (
56
+ "You are an expert tutor creating spaced-repetition flashcards. "
57
+ "Context is isolated; rely solely on the provided prompt."
58
+ )
59
+
60
+ raw_resp = sample(
61
+ invoke_id=invoke_id,
62
+ prompt=prompt,
63
+ system=system_prompt,
64
+ max_tokens=2048,
65
+ temperature=0.3,
66
+ schema=SCHEMA_CARDS,
67
+ schema_name="flashcards_output",
68
+ )
69
+
70
+ parsed = robust_json_parse(raw_resp)
71
+
72
+ # Normalize parsed data into a list of raw card objects
73
+ raw_list: list[dict[str, Any]] = []
74
+ if isinstance(parsed, list):
75
+ for item in parsed:
76
+ if isinstance(item, dict):
77
+ if "cards" in item and isinstance(item["cards"], list):
78
+ for sub in item["cards"]:
79
+ if isinstance(sub, dict):
80
+ raw_list.append(sub)
81
+ else:
82
+ raw_list.append(item)
83
+ elif isinstance(parsed, dict):
84
+ if "cards" in parsed and isinstance(parsed["cards"], list):
85
+ for item in parsed["cards"]:
86
+ if isinstance(item, dict):
87
+ raw_list.append(item)
88
+ elif "front" in parsed or "question" in parsed:
89
+ raw_list.append(parsed)
90
+
91
+ validated_cards: list[dict[str, str]] = []
92
+ seen_fronts: set[str] = set()
93
+ for item in raw_list:
94
+ front = str(
95
+ item.get("front") or item.get("question") or item.get("q") or ""
96
+ ).strip()
97
+ back = str(
98
+ item.get("back") or item.get("answer") or item.get("a") or ""
99
+ ).strip()
100
+ # A card missing either side carries no study value; emitting one would
101
+ # be exactly the placeholder output the platform review rejects.
102
+ if not front or not back:
103
+ continue
104
+ key = front.casefold()
105
+ if key in seen_fronts:
106
+ continue
107
+ seen_fronts.add(key)
108
+ validated_cards.append({
109
+ "front": front[:4000],
110
+ "back": back[:4000],
111
+ })
112
+
113
+ if not validated_cards:
114
+ raise ValueError("Failed to extract any valid flashcards from model response")
115
+
116
+ target_deck_id = (deck_id or "").strip()
117
+ persisted = False
118
+ persist_error: str | None = None
119
+
120
+ # An explicitly requested deck must exist. Failing loudly here is the whole
121
+ # point: the previous revision called store.deck_get/deck_create/card_add,
122
+ # none of which exist, and swallowed the AttributeError -- so every call
123
+ # reported a deck_id while writing nothing at all.
124
+ if store is not None and target_deck_id and store.get_deck(target_deck_id) is None:
125
+ raise ValueError(f"Deck not found: {target_deck_id}")
126
+
127
+ if store is not None:
128
+ try:
129
+ now = datetime.now(timezone.utc)
130
+ if not target_deck_id:
131
+ deck = Deck(
132
+ id=f"deck-{uuid.uuid4().hex[:12]}",
133
+ name=f"Topic: {topic[:30]}",
134
+ created_at=now,
135
+ )
136
+ store.save_deck(deck)
137
+ target_deck_id = deck.id
138
+
139
+ for item in validated_cards:
140
+ store.save_card(
141
+ Card(
142
+ id=f"card-{uuid.uuid4().hex[:12]}",
143
+ deck_id=target_deck_id,
144
+ front=item["front"],
145
+ back=item["back"],
146
+ srs=SM2State(
147
+ repetitions=0,
148
+ ease_factor=2.5,
149
+ interval=0,
150
+ next_review=now,
151
+ ),
152
+ created_at=now,
153
+ graded_count=0,
154
+ )
155
+ )
156
+ persisted = True
157
+ except Exception as e:
158
+ # The cards are already generated and the tokens already spent, so
159
+ # they stay in the response. What must never happen is reporting a
160
+ # successful save when nothing was written -- surface it instead.
161
+ persist_error = f"{type(e).__name__}: {e}"
162
+ print(
163
+ f"[cards] persistence failed for deck={target_deck_id!r}: {persist_error}",
164
+ file=sys.stderr,
165
+ )
166
+
167
+ return {
168
+ "generated": len(validated_cards),
169
+ "cards": validated_cards,
170
+ "deck_id": target_deck_id if persisted else "",
171
+ "persisted": persisted,
172
+ "persist_error": persist_error,
173
+ }
@@ -0,0 +1,140 @@
1
+ from typing import Any
2
+
3
+ from ..json_repair import robust_json_parse
4
+ from ..models import Mindmap, MindmapNode
5
+ from ..sampling import sample
6
+
7
+ LABEL_MAX = 120
8
+
9
+ SCHEMA_MINDMAP = {
10
+ "type": "object",
11
+ "properties": {
12
+ "topic": {"type": "string"},
13
+ "children": {
14
+ "type": "array",
15
+ "items": {
16
+ "type": "object",
17
+ "properties": {
18
+ "label": {"type": "string"},
19
+ "children": {
20
+ "type": "array",
21
+ },
22
+ },
23
+ "required": ["label"],
24
+ "additionalProperties": False,
25
+ },
26
+ },
27
+ },
28
+ "required": ["topic", "children"],
29
+ "additionalProperties": False,
30
+ }
31
+
32
+
33
+ def _clean_node(data: Any, current_depth: int, max_depth: int) -> MindmapNode | None:
34
+ if not isinstance(data, dict):
35
+ if isinstance(data, str) and data.strip():
36
+ return MindmapNode(label=data.strip()[:LABEL_MAX], children=[])
37
+ return None
38
+
39
+ label = str(
40
+ data.get("label") or data.get("title") or data.get("topic") or data.get("name") or ""
41
+ ).strip()
42
+ if not label:
43
+ return None
44
+
45
+ children: list[MindmapNode] = []
46
+ if current_depth < max_depth:
47
+ raw_children = data.get("children") or data.get("subtopics") or []
48
+ if isinstance(raw_children, list):
49
+ for child_item in raw_children:
50
+ child_node = _clean_node(child_item, current_depth + 1, max_depth)
51
+ if child_node:
52
+ children.append(child_node)
53
+
54
+ return MindmapNode(label=label[:LABEL_MAX], children=children)
55
+
56
+
57
+ def build_mindmap(
58
+ source_text: str,
59
+ max_depth: int = 3,
60
+ invoke_id: str = "",
61
+ ) -> dict[str, Any]:
62
+ source_text = (source_text or "").strip()
63
+ if not source_text:
64
+ raise ValueError("source_text must not be empty")
65
+
66
+ depth_limit = max(1, min(int(max_depth), 5))
67
+
68
+ prompt = (
69
+ f"Extract a hierarchical concept mindmap from the following text.\n"
70
+ f"Max depth level: {depth_limit}.\n\n"
71
+ f"Source Text:\n{source_text}\n\n"
72
+ "Requirements:\n"
73
+ "1. Extract a clear central 'topic' (root concept).\n"
74
+ "2. Break down into logical branches under 'children', each having 'label' and sub-'children'.\n"
75
+ "3. Return ONLY a JSON object with 'topic' and 'children' matching the schema."
76
+ )
77
+
78
+ system_prompt = (
79
+ "You are an expert concept visualizer. "
80
+ "Context is isolated; all text is supplied directly in the prompt."
81
+ )
82
+
83
+ raw_resp = sample(
84
+ invoke_id=invoke_id,
85
+ prompt=prompt,
86
+ system=system_prompt,
87
+ max_tokens=2560,
88
+ temperature=0.3,
89
+ schema=SCHEMA_MINDMAP,
90
+ schema_name="mindmap_output",
91
+ )
92
+
93
+ parsed = robust_json_parse(raw_resp)
94
+
95
+ # Normalize parsed structure into topic and children
96
+ root_topic = ""
97
+ raw_children: list[Any] = []
98
+
99
+ root_dict: dict[str, Any] = {}
100
+ if isinstance(parsed, dict):
101
+ root_dict = parsed
102
+ elif isinstance(parsed, list):
103
+ if len(parsed) == 1 and isinstance(parsed[0], dict) and ("topic" in parsed[0] or "children" in parsed[0]):
104
+ root_dict = parsed[0]
105
+ else:
106
+ raw_children = parsed
107
+
108
+ if root_dict:
109
+ root_topic = str(
110
+ root_dict.get("topic") or root_dict.get("label") or root_dict.get("title") or ""
111
+ ).strip()
112
+ raw_children = root_dict.get("children") or root_dict.get("subtopics") or []
113
+ if not isinstance(raw_children, list):
114
+ raw_children = []
115
+
116
+ if not root_topic:
117
+ # Fall back to the user's own text rather than an invented "Mind Map"
118
+ # label -- the first real line is at least derived from the input.
119
+ for line in source_text.splitlines():
120
+ candidate = line.strip()
121
+ if candidate:
122
+ root_topic = candidate[:40]
123
+ break
124
+
125
+ if not root_topic:
126
+ raise ValueError("Could not determine a mindmap root topic from model response")
127
+
128
+ nodes: list[MindmapNode] = []
129
+ for item in raw_children:
130
+ node = _clean_node(item, current_depth=1, max_depth=depth_limit)
131
+ if node:
132
+ nodes.append(node)
133
+
134
+ if not nodes:
135
+ # A lone "Overview" node is a placeholder, not a mindmap. Fail instead
136
+ # of presenting an empty tree as a result.
137
+ raise ValueError("Failed to extract any mindmap branches from model response")
138
+
139
+ mindmap = Mindmap(topic=root_topic[:LABEL_MAX], children=nodes)
140
+ return mindmap.model_dump()
@@ -0,0 +1,161 @@
1
+ from typing import Any
2
+
3
+ from ..json_repair import robust_json_parse
4
+ from ..models import QuizQuestion
5
+ from ..sampling import sample
6
+
7
+ SCHEMA_QUIZ = {
8
+ "type": "object",
9
+ "properties": {
10
+ "questions": {
11
+ "type": "array",
12
+ "items": {
13
+ "type": "object",
14
+ "properties": {
15
+ "question": {"type": "string"},
16
+ "options": {
17
+ "type": "array",
18
+ "items": {"type": "string"},
19
+ "minItems": 4,
20
+ "maxItems": 4,
21
+ },
22
+ "correct_index": {
23
+ "type": "integer",
24
+ "minimum": 0,
25
+ "maximum": 3,
26
+ },
27
+ "explanation": {"type": "string"},
28
+ },
29
+ "required": ["question", "options", "correct_index", "explanation"],
30
+ "additionalProperties": False,
31
+ },
32
+ }
33
+ },
34
+ "required": ["questions"],
35
+ "additionalProperties": False,
36
+ }
37
+
38
+
39
+ def generate_quiz(
40
+ topic: str,
41
+ count: int = 5,
42
+ difficulty: str = "medium",
43
+ source_text: str | None = None,
44
+ invoke_id: str = "",
45
+ ) -> dict[str, Any]:
46
+ topic = (topic or "").strip()
47
+ if not topic:
48
+ raise ValueError("topic must not be empty")
49
+
50
+ count = max(1, min(int(count), 20))
51
+ difficulty = difficulty.strip().lower() if difficulty else "medium"
52
+ if difficulty not in ("easy", "medium", "hard"):
53
+ difficulty = "medium"
54
+
55
+ source_prompt = f"\n\nSource context / reference text:\n{source_text.strip()}" if source_text and source_text.strip() else ""
56
+
57
+ prompt = (
58
+ f"Generate exactly {count} multiple-choice quiz questions on the topic: '{topic}'.\n"
59
+ f"Difficulty: {difficulty}.{source_prompt}\n\n"
60
+ "Requirements:\n"
61
+ "1. Each question must have exactly 4 distinct, plausible options.\n"
62
+ "2. 'correct_index' must be 0, 1, 2, or 3 pointing to the single correct option.\n"
63
+ "3. 'explanation' must explain why the correct option is right and the others wrong.\n"
64
+ "4. Return ONLY a JSON object with a 'questions' array matching the schema."
65
+ )
66
+
67
+ system_prompt = (
68
+ "You are an expert assessment examiner. "
69
+ "Context is isolated; all reference information is supplied in the user prompt."
70
+ )
71
+
72
+ raw_resp = sample(
73
+ invoke_id=invoke_id,
74
+ prompt=prompt,
75
+ system=system_prompt,
76
+ max_tokens=2560,
77
+ temperature=0.3,
78
+ schema=SCHEMA_QUIZ,
79
+ schema_name="quiz_output",
80
+ )
81
+
82
+ parsed = robust_json_parse(raw_resp)
83
+
84
+ raw_questions: list[dict[str, Any]] = []
85
+ if isinstance(parsed, list):
86
+ for item in parsed:
87
+ if isinstance(item, dict):
88
+ if "questions" in item and isinstance(item["questions"], list):
89
+ for sub in item["questions"]:
90
+ if isinstance(sub, dict):
91
+ raw_questions.append(sub)
92
+ else:
93
+ raw_questions.append(item)
94
+ elif isinstance(parsed, dict):
95
+ if "questions" in parsed and isinstance(parsed["questions"], list):
96
+ for item in parsed["questions"]:
97
+ if isinstance(item, dict):
98
+ raw_questions.append(item)
99
+ elif "question" in parsed:
100
+ raw_questions.append(parsed)
101
+
102
+ validated_questions: list[dict[str, Any]] = []
103
+ for item in raw_questions:
104
+ q_text = str(item.get("question") or "").strip()
105
+
106
+ raw_opts = item.get("options")
107
+ if not isinstance(raw_opts, list):
108
+ continue
109
+ opts: list[str] = []
110
+ seen_opts: set[str] = set()
111
+ for o in raw_opts:
112
+ text = str(o).strip()
113
+ key = text.casefold()
114
+ if not text or key in seen_opts:
115
+ continue
116
+ seen_opts.add(key)
117
+ opts.append(text)
118
+
119
+ # The schema demands exactly 4 distinct options. Padding the list with
120
+ # synthetic "Option C"/"Option D" text would be the static placeholder
121
+ # output the platform explicitly excludes from a qualified run, so a
122
+ # question we cannot assemble correctly is dropped rather than faked.
123
+ if not q_text or len(opts) < 4:
124
+ continue
125
+
126
+ raw_idx = item.get("correct_index")
127
+ if raw_idx is None:
128
+ raw_idx = item.get("correctIndex")
129
+ if raw_idx is None:
130
+ raw_idx = item.get("correct")
131
+ try:
132
+ correct_idx = int(raw_idx) # type: ignore[arg-type]
133
+ except (ValueError, TypeError):
134
+ # Defaulting to 0 would silently declare the first option correct
135
+ # and teach the user a wrong answer -- strictly worse than dropping.
136
+ continue
137
+
138
+ if len(opts) > 4:
139
+ # Only trim when the answer index survives the cut.
140
+ if correct_idx >= 4:
141
+ continue
142
+ opts = opts[:4]
143
+
144
+ if not 0 <= correct_idx <= 3:
145
+ continue
146
+
147
+ explanation = str(item.get("explanation") or "").strip()
148
+
149
+ # Validate with Pydantic model
150
+ q_model = QuizQuestion(
151
+ question=q_text,
152
+ options=opts,
153
+ correct_index=correct_idx,
154
+ explanation=explanation,
155
+ )
156
+ validated_questions.append(q_model.model_dump())
157
+
158
+ if not validated_questions:
159
+ raise ValueError("Failed to extract valid quiz questions from model response")
160
+
161
+ return {"questions": validated_questions}