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.
- gemmate_srs-0.1.0/PKG-INFO +74 -0
- gemmate_srs-0.1.0/README.md +55 -0
- gemmate_srs-0.1.0/gemmate_srs/__init__.py +1 -0
- gemmate_srs-0.1.0/gemmate_srs/engines/__init__.py +13 -0
- gemmate_srs-0.1.0/gemmate_srs/engines/cards.py +173 -0
- gemmate_srs-0.1.0/gemmate_srs/engines/mindmap.py +140 -0
- gemmate_srs-0.1.0/gemmate_srs/engines/quiz.py +161 -0
- gemmate_srs-0.1.0/gemmate_srs/engines/solver.py +140 -0
- gemmate_srs-0.1.0/gemmate_srs/json_repair.py +150 -0
- gemmate_srs-0.1.0/gemmate_srs/models.py +73 -0
- gemmate_srs-0.1.0/gemmate_srs/plugin.py +378 -0
- gemmate_srs-0.1.0/gemmate_srs/protocol.py +199 -0
- gemmate_srs-0.1.0/gemmate_srs/sampling.py +195 -0
- gemmate_srs-0.1.0/gemmate_srs/sm2.py +65 -0
- gemmate_srs-0.1.0/gemmate_srs/store.py +156 -0
- gemmate_srs-0.1.0/gemmate_srs/tools.py +420 -0
- gemmate_srs-0.1.0/gemmate_srs.egg-info/PKG-INFO +74 -0
- gemmate_srs-0.1.0/gemmate_srs.egg-info/SOURCES.txt +31 -0
- gemmate_srs-0.1.0/gemmate_srs.egg-info/dependency_links.txt +1 -0
- gemmate_srs-0.1.0/gemmate_srs.egg-info/entry_points.txt +2 -0
- gemmate_srs-0.1.0/gemmate_srs.egg-info/requires.txt +4 -0
- gemmate_srs-0.1.0/gemmate_srs.egg-info/top_level.txt +1 -0
- gemmate_srs-0.1.0/pyproject.toml +42 -0
- gemmate_srs-0.1.0/setup.cfg +4 -0
- gemmate_srs-0.1.0/tests/test_engines.py +374 -0
- gemmate_srs-0.1.0/tests/test_json_repair.py +86 -0
- gemmate_srs-0.1.0/tests/test_plugin.py +138 -0
- gemmate_srs-0.1.0/tests/test_protocol.py +99 -0
- gemmate_srs-0.1.0/tests/test_sampling.py +183 -0
- gemmate_srs-0.1.0/tests/test_sm2.py +86 -0
- gemmate_srs-0.1.0/tests/test_store.py +191 -0
- gemmate_srs-0.1.0/tests/test_tools.py +178 -0
- 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}
|