studyctl 2.0.0__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 (58) hide show
  1. studyctl/__init__.py +3 -0
  2. studyctl/calendar.py +140 -0
  3. studyctl/cli/__init__.py +56 -0
  4. studyctl/cli/_config.py +128 -0
  5. studyctl/cli/_content.py +462 -0
  6. studyctl/cli/_lazy.py +35 -0
  7. studyctl/cli/_review.py +491 -0
  8. studyctl/cli/_schedule.py +125 -0
  9. studyctl/cli/_setup.py +164 -0
  10. studyctl/cli/_shared.py +83 -0
  11. studyctl/cli/_state.py +69 -0
  12. studyctl/cli/_sync.py +156 -0
  13. studyctl/cli/_web.py +228 -0
  14. studyctl/content/__init__.py +5 -0
  15. studyctl/content/markdown_converter.py +271 -0
  16. studyctl/content/models.py +31 -0
  17. studyctl/content/notebooklm_client.py +434 -0
  18. studyctl/content/splitter.py +159 -0
  19. studyctl/content/storage.py +105 -0
  20. studyctl/content/syllabus.py +416 -0
  21. studyctl/history.py +982 -0
  22. studyctl/maintenance.py +69 -0
  23. studyctl/mcp/__init__.py +1 -0
  24. studyctl/mcp/server.py +58 -0
  25. studyctl/mcp/tools.py +234 -0
  26. studyctl/pdf.py +89 -0
  27. studyctl/review_db.py +277 -0
  28. studyctl/review_loader.py +375 -0
  29. studyctl/scheduler.py +242 -0
  30. studyctl/services/__init__.py +6 -0
  31. studyctl/services/content.py +39 -0
  32. studyctl/services/review.py +127 -0
  33. studyctl/settings.py +367 -0
  34. studyctl/shared.py +425 -0
  35. studyctl/state.py +120 -0
  36. studyctl/sync.py +229 -0
  37. studyctl/tui/__main__.py +33 -0
  38. studyctl/tui/app.py +395 -0
  39. studyctl/tui/study_cards.py +396 -0
  40. studyctl/web/__init__.py +1 -0
  41. studyctl/web/app.py +68 -0
  42. studyctl/web/routes/__init__.py +1 -0
  43. studyctl/web/routes/artefacts.py +57 -0
  44. studyctl/web/routes/cards.py +86 -0
  45. studyctl/web/routes/courses.py +91 -0
  46. studyctl/web/routes/history.py +69 -0
  47. studyctl/web/server.py +260 -0
  48. studyctl/web/static/app.js +853 -0
  49. studyctl/web/static/icon-192.svg +4 -0
  50. studyctl/web/static/icon-512.svg +4 -0
  51. studyctl/web/static/index.html +50 -0
  52. studyctl/web/static/manifest.json +21 -0
  53. studyctl/web/static/style.css +657 -0
  54. studyctl/web/static/sw.js +14 -0
  55. studyctl-2.0.0.dist-info/METADATA +49 -0
  56. studyctl-2.0.0.dist-info/RECORD +58 -0
  57. studyctl-2.0.0.dist-info/WHEEL +4 -0
  58. studyctl-2.0.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,396 @@
1
+ """Interactive flashcard and quiz review tab for the studyctl TUI.
2
+
3
+ Provides keyboard-driven study with spaced repetition tracking
4
+ and optional voice output via study-speak.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import sqlite3
11
+ import time
12
+ from enum import StrEnum
13
+ from typing import TYPE_CHECKING, ClassVar
14
+
15
+ try:
16
+ from textual.binding import Binding
17
+ from textual.containers import Center, Horizontal
18
+ from textual.reactive import reactive
19
+ from textual.widget import Widget
20
+ from textual.widgets import Button, Static
21
+ except ImportError as _exc:
22
+ raise ImportError("The TUI requires 'textual'. Install: pip install studyctl[tui]") from _exc
23
+
24
+ if TYPE_CHECKING:
25
+ from textual.app import ComposeResult
26
+
27
+ from studyctl.review_db import record_card_review, record_session
28
+ from studyctl.review_loader import (
29
+ Flashcard,
30
+ QuizQuestion,
31
+ ReviewResult,
32
+ )
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ class StudyMode(StrEnum):
38
+ """Tracks whether the user is in a normal or retry study session."""
39
+
40
+ NORMAL = "normal"
41
+ RETRY = "retry"
42
+
43
+
44
+ class CardPanel(Static):
45
+ """Displays a flashcard or quiz question with flip support."""
46
+
47
+ revealed = reactive(False)
48
+
49
+ def __init__(self, **kwargs: object) -> None:
50
+ super().__init__(**kwargs)
51
+ self._front = ""
52
+ self._back = ""
53
+
54
+ def set_card(self, front: str, back: str) -> None:
55
+ self._front = front
56
+ self._back = back
57
+ self.revealed = False
58
+ self.update(self._front)
59
+
60
+ def flip(self) -> None:
61
+ self.revealed = not self.revealed
62
+ self.update(self._back if self.revealed else self._front)
63
+
64
+
65
+ class StudyCardsTab(Widget):
66
+ """Interactive flashcard and quiz review widget."""
67
+
68
+ can_focus = True
69
+
70
+ DEFAULT_CSS = """
71
+ StudyCardsTab {
72
+ layout: vertical;
73
+ padding: 1 2;
74
+ }
75
+ #card-panel {
76
+ height: auto;
77
+ min-height: 5;
78
+ padding: 1 2;
79
+ border: round $accent;
80
+ margin: 1 0;
81
+ }
82
+ #card-panel.revealed {
83
+ border: round $success;
84
+ }
85
+ #score-bar {
86
+ height: 3;
87
+ dock: bottom;
88
+ margin-top: 1;
89
+ }
90
+ #progress-label {
91
+ text-align: center;
92
+ margin: 1 0;
93
+ }
94
+ #status-label {
95
+ text-align: center;
96
+ color: $text-muted;
97
+ }
98
+ #voice-label {
99
+ text-align: right;
100
+ color: $text-muted;
101
+ }
102
+ .score-btn {
103
+ margin: 0 1;
104
+ }
105
+ """
106
+
107
+ BINDINGS: ClassVar[list[Binding]] = [
108
+ Binding("space", "flip", "Flip / Submit"),
109
+ Binding("y", "mark_correct", "Correct"),
110
+ Binding("n", "mark_incorrect", "Incorrect"),
111
+ Binding("s", "skip_card", "Skip"),
112
+ Binding("h", "show_hint", "Hint"),
113
+ Binding("v", "toggle_voice", "Voice"),
114
+ Binding("r", "retry_wrong", "Retry Wrong"),
115
+ ]
116
+
117
+ current_index = reactive(0)
118
+ voice_enabled = reactive(False)
119
+ _study_mode: reactive[StudyMode] = reactive(StudyMode.NORMAL, bindings=True)
120
+
121
+ def __init__(
122
+ self,
123
+ cards: list[Flashcard | QuizQuestion],
124
+ course_name: str = "",
125
+ mode: str = "flashcards",
126
+ **kwargs: object,
127
+ ) -> None:
128
+ super().__init__(**kwargs)
129
+ self._all_cards = list(cards)
130
+ self._cards = cards
131
+ self._course = course_name
132
+ self._mode = mode
133
+ self._result = ReviewResult(total=len(cards))
134
+ self._start_time = time.monotonic()
135
+ self._card_start_time = time.monotonic()
136
+
137
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
138
+ if action == "retry_wrong":
139
+ return bool(self._result.wrong_hashes) and self._study_mode is not StudyMode.RETRY
140
+ return True
141
+
142
+ def compose(self) -> ComposeResult:
143
+ yield Static(
144
+ f"[bold]{self._course}[/bold] — {self._mode} ({len(self._cards)} items)",
145
+ id="status-label",
146
+ )
147
+ voice_text = "Voice: ON" if self.voice_enabled else "Voice: OFF (v to toggle)"
148
+ yield Static(voice_text, id="voice-label")
149
+ yield CardPanel(id="card-panel")
150
+ yield Static("", id="progress-label")
151
+ with Center(), Horizontal(id="score-bar"):
152
+ yield Button(
153
+ "Know (y)",
154
+ variant="success",
155
+ id="btn-correct",
156
+ classes="score-btn",
157
+ )
158
+ yield Button(
159
+ "Don't Know (n)",
160
+ variant="error",
161
+ id="btn-incorrect",
162
+ classes="score-btn",
163
+ )
164
+ yield Button(
165
+ "Skip (s)",
166
+ variant="default",
167
+ id="btn-skip",
168
+ classes="score-btn",
169
+ )
170
+
171
+ def on_mount(self) -> None:
172
+ self.focus()
173
+ self._show_current_card()
174
+
175
+ def on_button_pressed(self, event: Button.Pressed) -> None:
176
+ if event.button.id == "btn-correct":
177
+ self.action_mark_correct()
178
+ elif event.button.id == "btn-incorrect":
179
+ self.action_mark_incorrect()
180
+ elif event.button.id == "btn-skip":
181
+ self.action_skip_card()
182
+
183
+ def _show_current_card(self) -> None:
184
+ if self.current_index >= len(self._cards):
185
+ self._show_summary()
186
+ return
187
+
188
+ card = self._cards[self.current_index]
189
+ panel = self.query_one("#card-panel", CardPanel)
190
+ self._card_start_time = time.monotonic()
191
+
192
+ if isinstance(card, Flashcard):
193
+ panel.set_card(
194
+ f"[bold]Q:[/bold] {card.front}\n\n[dim]Press Space to reveal answer[/dim]",
195
+ f"[bold]A:[/bold] {card.back}",
196
+ )
197
+ else:
198
+ # Quiz question
199
+ lines = [f"[bold]Q:[/bold] {card.question}\n"]
200
+ letters = "abcdefghij"
201
+ for j, opt in enumerate(card.options):
202
+ lines.append(f" [bold]{letters[j]})[/bold] {opt.text}")
203
+ lines.append("\n[dim]Press Space to reveal answer[/dim]")
204
+ panel.set_card(
205
+ "\n".join(lines),
206
+ self._format_quiz_answer(card),
207
+ )
208
+
209
+ # Speak the question if voice enabled
210
+ if self.voice_enabled:
211
+ text = card.front if isinstance(card, Flashcard) else card.question
212
+ self._speak(text)
213
+
214
+ retry_tag = " (Retry)" if self._study_mode is StudyMode.RETRY else ""
215
+ progress = f"Card {self.current_index + 1}/{len(self._cards)}{retry_tag}"
216
+ if self._result.correct + self._result.incorrect > 0:
217
+ progress += f" | Score: {self._result.score_pct:.0f}%"
218
+ self.query_one("#progress-label", Static).update(progress)
219
+
220
+ def _format_quiz_answer(self, q: QuizQuestion) -> str:
221
+ letters = "abcdefghij"
222
+ correct_idx = next((i for i, o in enumerate(q.options) if o.is_correct), 0)
223
+ correct_opt = q.options[correct_idx]
224
+ lines = [f"[green bold]Answer: {letters[correct_idx]})[/green bold] {correct_opt.text}"]
225
+ if correct_opt.rationale:
226
+ lines.append(f"\n[dim]{correct_opt.rationale}[/dim]")
227
+ return "\n".join(lines)
228
+
229
+ def _record_answer(self, correct: bool) -> None:
230
+ card = self._cards[self.current_index]
231
+ elapsed_ms = int((time.monotonic() - self._card_start_time) * 1000)
232
+
233
+ if correct:
234
+ self._result.correct += 1
235
+ else:
236
+ self._result.incorrect += 1
237
+ self._result.wrong_hashes.add(card.card_hash)
238
+
239
+ # Record to DB (skip SM-2 during retry)
240
+ if self._study_mode is not StudyMode.RETRY:
241
+ card_type = "flashcard" if isinstance(card, Flashcard) else "quiz"
242
+ try:
243
+ record_card_review(
244
+ course=self._course,
245
+ card_type=card_type,
246
+ card_hash=card.card_hash,
247
+ correct=correct,
248
+ response_time_ms=elapsed_ms,
249
+ )
250
+ except (sqlite3.Error, OSError) as exc:
251
+ logger.debug("Failed to record card review: %s", exc)
252
+
253
+ self.current_index += 1
254
+ self._show_current_card()
255
+
256
+ def _show_summary(self) -> None:
257
+ duration = int(time.monotonic() - self._start_time)
258
+ attempted = self._result.correct + self._result.incorrect
259
+ pct = self._result.score_pct
260
+
261
+ if pct >= 80:
262
+ grade = "[green]Excellent[/green]"
263
+ elif pct >= 60:
264
+ grade = "[yellow]Good[/yellow]"
265
+ else:
266
+ grade = "[red]Needs review[/red]"
267
+
268
+ wrong_count = len(self._result.wrong_hashes)
269
+ summary = [
270
+ "[bold]Session Complete![/bold]",
271
+ "",
272
+ f" Score: {self._result.correct}/{attempted} ({pct:.0f}%) — {grade}",
273
+ f" Skipped: {self._result.skipped}",
274
+ f" Duration: {duration // 60}m {duration % 60}s",
275
+ ]
276
+ if wrong_count and self._study_mode is not StudyMode.RETRY:
277
+ summary.append(
278
+ f"\n [yellow]{wrong_count} cards to review again — press r to retry[/yellow]"
279
+ )
280
+
281
+ panel = self.query_one("#card-panel", CardPanel)
282
+ panel.update("\n".join(summary))
283
+
284
+ # Hide score buttons
285
+ for btn_id in ("btn-correct", "btn-incorrect", "btn-skip"):
286
+ self.query_one(f"#{btn_id}", Button).display = False
287
+
288
+ hint = "[bold]Press q to return[/bold]"
289
+ if self._result.wrong_hashes and self._study_mode is not StudyMode.RETRY:
290
+ hint = "[bold]Press r to retry wrong, q to return[/bold]"
291
+ self.query_one("#progress-label", Static).update(hint)
292
+
293
+ # Record session
294
+ try:
295
+ record_session(
296
+ course=self._course,
297
+ mode=self._mode,
298
+ total=self._result.total,
299
+ correct=self._result.correct,
300
+ duration_seconds=duration,
301
+ )
302
+ except (sqlite3.Error, OSError) as exc:
303
+ logger.debug("Failed to record session: %s", exc)
304
+
305
+ def _speak(self, text: str) -> None:
306
+ """Speak text via study-speak (non-blocking, best-effort)."""
307
+ try:
308
+ from agent_session_tools.speak import (
309
+ _get_tts_config,
310
+ _speak_kokoro,
311
+ )
312
+
313
+ cfg = _get_tts_config()
314
+ voice = cfg.get("voice", "am_michael")
315
+ speed = cfg.get("speed", 1.0)
316
+ import threading
317
+
318
+ threading.Thread(
319
+ target=_speak_kokoro,
320
+ args=(text,),
321
+ kwargs={"voice": voice, "speed": speed},
322
+ daemon=True,
323
+ ).start()
324
+ except (ImportError, OSError, RuntimeError) as exc:
325
+ logger.debug("Voice unavailable: %s", exc)
326
+
327
+ # --- Actions ---
328
+
329
+ def action_flip(self) -> None:
330
+ panel = self.query_one("#card-panel", CardPanel)
331
+ if not panel.revealed:
332
+ panel.flip()
333
+ panel.add_class("revealed")
334
+ if self.voice_enabled and self.current_index < len(self._cards):
335
+ card = self._cards[self.current_index]
336
+ text = card.back if isinstance(card, Flashcard) else ""
337
+ if text:
338
+ self._speak(text)
339
+
340
+ def action_mark_correct(self) -> None:
341
+ panel = self.query_one("#card-panel", CardPanel)
342
+ if panel.revealed:
343
+ panel.remove_class("revealed")
344
+ self._record_answer(correct=True)
345
+
346
+ def action_mark_incorrect(self) -> None:
347
+ panel = self.query_one("#card-panel", CardPanel)
348
+ if panel.revealed:
349
+ panel.remove_class("revealed")
350
+ self._record_answer(correct=False)
351
+
352
+ def action_skip_card(self) -> None:
353
+ self._result.skipped += 1
354
+ panel = self.query_one("#card-panel", CardPanel)
355
+ panel.remove_class("revealed")
356
+ self.current_index += 1
357
+ self._show_current_card()
358
+
359
+ def action_show_hint(self) -> None:
360
+ if self.current_index >= len(self._cards):
361
+ return
362
+ card = self._cards[self.current_index]
363
+ if isinstance(card, QuizQuestion) and card.hint:
364
+ self.notify(f"Hint: {card.hint}", title="Hint")
365
+
366
+ def action_toggle_voice(self) -> None:
367
+ self.voice_enabled = not self.voice_enabled
368
+ label = self.query_one("#voice-label", Static)
369
+ if self.voice_enabled:
370
+ label.update("[green]Voice: ON[/green]")
371
+ self.notify("Voice enabled")
372
+ else:
373
+ label.update("Voice: OFF (v to toggle)")
374
+ self.notify("Voice disabled")
375
+
376
+ def action_retry_wrong(self) -> None:
377
+ """Retry only the incorrectly answered cards."""
378
+ if not self._result.wrong_hashes or self._study_mode is StudyMode.RETRY:
379
+ return
380
+
381
+ wrong = self._result.wrong_hashes
382
+ retry_cards = [c for c in self._all_cards if c.card_hash in wrong]
383
+ if not retry_cards:
384
+ return
385
+
386
+ self._cards = retry_cards
387
+ self._result = ReviewResult(total=len(retry_cards))
388
+ self._study_mode = StudyMode.RETRY
389
+ self.current_index = 0
390
+ self._start_time = time.monotonic()
391
+
392
+ # Re-show score buttons
393
+ for btn_id in ("btn-correct", "btn-incorrect", "btn-skip"):
394
+ self.query_one(f"#{btn_id}", Button).display = True
395
+
396
+ self._show_current_card()
@@ -0,0 +1 @@
1
+ """Lightweight PWA web server for flashcard and quiz review."""
studyctl/web/app.py ADDED
@@ -0,0 +1,68 @@
1
+ """FastAPI application factory for the study PWA.
2
+
3
+ Replaces the stdlib http.server with FastAPI + uvicorn.
4
+ Serves JSON API endpoints and static PWA files.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ from fastapi import FastAPI, Request
13
+ from fastapi.responses import FileResponse
14
+ from fastapi.staticfiles import StaticFiles
15
+ from starlette.middleware.base import BaseHTTPMiddleware
16
+
17
+ if TYPE_CHECKING:
18
+ from starlette.responses import Response
19
+
20
+ STATIC_DIR = Path(__file__).parent / "static"
21
+
22
+
23
+ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
24
+ """Add security headers to all responses."""
25
+
26
+ async def dispatch(self, request: Request, call_next) -> Response: # type: ignore[override]
27
+ response = await call_next(request)
28
+ response.headers["X-Content-Type-Options"] = "nosniff"
29
+ response.headers["X-Frame-Options"] = "DENY"
30
+ response.headers["X-XSS-Protection"] = "1; mode=block"
31
+ return response
32
+
33
+
34
+ def create_app(study_dirs: list[str] | None = None) -> FastAPI:
35
+ """Create and configure the FastAPI application.
36
+
37
+ Args:
38
+ study_dirs: List of directory paths containing flashcard/quiz content.
39
+ """
40
+ app = FastAPI(
41
+ title="Socratic Study Mentor",
42
+ docs_url=None,
43
+ redoc_url=None,
44
+ )
45
+
46
+ # Store config on app state for route access
47
+ app.state.study_dirs = study_dirs or []
48
+
49
+ # Security headers
50
+ app.add_middleware(SecurityHeadersMiddleware)
51
+
52
+ # Register API routes
53
+ from studyctl.web.routes import artefacts, cards, courses, history
54
+
55
+ app.include_router(courses.router, prefix="/api")
56
+ app.include_router(cards.router, prefix="/api")
57
+ app.include_router(history.router, prefix="/api")
58
+ app.include_router(artefacts.router)
59
+
60
+ # Serve index.html at root
61
+ @app.get("/")
62
+ async def index() -> FileResponse:
63
+ return FileResponse(STATIC_DIR / "index.html")
64
+
65
+ # Mount static files LAST (catch-all)
66
+ app.mount("/", StaticFiles(directory=str(STATIC_DIR)), name="static")
67
+
68
+ return app
@@ -0,0 +1 @@
1
+ """FastAPI route modules."""
@@ -0,0 +1,57 @@
1
+ """Artefact serving routes with path traversal protection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path # noqa: TC003 — used at runtime for resolve/is_relative_to
6
+
7
+ from fastapi import APIRouter, HTTPException
8
+ from fastapi.responses import FileResponse
9
+
10
+ from studyctl.settings import load_settings
11
+
12
+ router = APIRouter()
13
+
14
+
15
+ def _validate_artefact_path(course: str, artefact_type: str, filename: str) -> Path:
16
+ """Resolve and validate artefact path against directory traversal.
17
+
18
+ Ensures the resolved path is a child of content.base_path.
19
+ Raises 404 if path escapes the base or file doesn't exist.
20
+ """
21
+ base = load_settings().content.base_path
22
+ resolved = (base / course / artefact_type / filename).resolve()
23
+ if not resolved.is_relative_to(base.resolve()):
24
+ raise HTTPException(status_code=404)
25
+ if not resolved.is_file():
26
+ raise HTTPException(status_code=404)
27
+ return resolved
28
+
29
+
30
+ @router.get("/artefacts/{course}/{artefact_type}/{filename:path}")
31
+ def serve_artefact(course: str, artefact_type: str, filename: str) -> FileResponse:
32
+ """Serve an artefact file (audio, video, slides, etc.) with path validation."""
33
+ path = _validate_artefact_path(course, artefact_type, filename)
34
+ return FileResponse(path)
35
+
36
+
37
+ @router.get("/api/artefacts/{course}")
38
+ def list_artefacts(course: str) -> list[dict]:
39
+ """List all artefacts for a course grouped by type."""
40
+ base = load_settings().content.base_path
41
+ course_dir = base / course
42
+ if not course_dir.is_dir():
43
+ raise HTTPException(status_code=404, detail=f"Course '{course}' not found")
44
+
45
+ result = []
46
+ for type_dir in sorted(course_dir.iterdir()):
47
+ if not type_dir.is_dir() or type_dir.name.startswith("."):
48
+ continue
49
+ files = sorted(f.name for f in type_dir.iterdir() if f.is_file())
50
+ if files:
51
+ result.append(
52
+ {
53
+ "type": type_dir.name,
54
+ "files": files,
55
+ }
56
+ )
57
+ return result
@@ -0,0 +1,86 @@
1
+ """Card API routes — load cards/quizzes, record reviews."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, HTTPException, Request
6
+ from pydantic import BaseModel
7
+
8
+ from studyctl.review_db import record_card_review
9
+ from studyctl.review_loader import (
10
+ discover_directories,
11
+ find_content_dirs,
12
+ load_flashcards,
13
+ load_quizzes,
14
+ )
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ class ReviewRequest(BaseModel):
20
+ """POST /api/review request body."""
21
+
22
+ course: str
23
+ card_hash: str
24
+ correct: bool
25
+ card_type: str = "flashcard"
26
+ response_time_ms: int | None = None
27
+
28
+
29
+ @router.get("/cards/{course}")
30
+ def get_cards(request: Request, course: str, mode: str = "flashcards") -> list[dict]:
31
+ """Load flashcards or quiz questions for a course."""
32
+ courses = discover_directories(request.app.state.study_dirs)
33
+ match = next(((n, p) for n, p in courses if n == course), None)
34
+ if not match:
35
+ raise HTTPException(status_code=404, detail=f"Course '{course}' not found")
36
+
37
+ _, path = match
38
+ fc_dir, quiz_dir = find_content_dirs(path)
39
+
40
+ if mode == "flashcards" and fc_dir:
41
+ cards = load_flashcards(fc_dir)
42
+ return [
43
+ {
44
+ "type": "flashcard",
45
+ "front": c.front,
46
+ "back": c.back,
47
+ "hash": c.card_hash,
48
+ "source": c.source,
49
+ }
50
+ for c in cards
51
+ ]
52
+ if mode == "quiz" and quiz_dir:
53
+ questions = load_quizzes(quiz_dir)
54
+ return [
55
+ {
56
+ "type": "quiz",
57
+ "question": q.question,
58
+ "options": [
59
+ {
60
+ "text": o.text,
61
+ "is_correct": o.is_correct,
62
+ "rationale": o.rationale,
63
+ }
64
+ for o in q.options
65
+ ],
66
+ "hint": q.hint,
67
+ "hash": q.card_hash,
68
+ "source": q.source,
69
+ }
70
+ for q in questions
71
+ ]
72
+
73
+ raise HTTPException(status_code=404, detail=f"No {mode} content for {course}")
74
+
75
+
76
+ @router.post("/review")
77
+ def post_review(body: ReviewRequest) -> dict:
78
+ """Record a single card review result."""
79
+ record_card_review(
80
+ course=body.course,
81
+ card_type=body.card_type,
82
+ card_hash=body.card_hash,
83
+ correct=body.correct,
84
+ response_time_ms=body.response_time_ms,
85
+ )
86
+ return {"ok": True}