learnx-cli 0.3.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 (131) hide show
  1. learnx_cli-0.3.0.dist-info/METADATA +240 -0
  2. learnx_cli-0.3.0.dist-info/RECORD +131 -0
  3. learnx_cli-0.3.0.dist-info/WHEEL +4 -0
  4. learnx_cli-0.3.0.dist-info/entry_points.txt +2 -0
  5. tutor/.env copy.example +4 -0
  6. tutor/__init__.py +0 -0
  7. tutor/__main__.py +4 -0
  8. tutor/assets/__init__.py +5 -0
  9. tutor/assets/html/fonts/Inter-Bold.woff2 +0 -0
  10. tutor/assets/html/fonts/Inter-Regular.woff2 +0 -0
  11. tutor/assets/html/fonts/Inter-SemiBold.woff2 +0 -0
  12. tutor/assets/html/fonts/JetBrainsMono-Regular.woff2 +0 -0
  13. tutor/assets/html/highlight-java.min.js +2 -0
  14. tutor/assets/html/highlight-javascript.min.js +2 -0
  15. tutor/assets/html/highlight-python.min.js +2 -0
  16. tutor/assets/html/highlight.min.js +17 -0
  17. tutor/assets/html/mermaid.min.js +31 -0
  18. tutor/assets/html/slide_base.css +464 -0
  19. tutor/assets/html/theme-learnx-dark.css +12 -0
  20. tutor/audio/__init__.py +0 -0
  21. tutor/audio/audio_builder.py +143 -0
  22. tutor/audio/sanitizer.py +9 -0
  23. tutor/audio/tts_renderer.py +54 -0
  24. tutor/cli/__init__.py +0 -0
  25. tutor/cli/commands.py +391 -0
  26. tutor/cli/logo.py +21 -0
  27. tutor/cli/playback_commands.py +239 -0
  28. tutor/cli/shell.py +91 -0
  29. tutor/cli/shell_context.py +18 -0
  30. tutor/cli/theme.py +39 -0
  31. tutor/cli/video_commands.py +123 -0
  32. tutor/config.py +122 -0
  33. tutor/conftest.py +5 -0
  34. tutor/constants.py +82 -0
  35. tutor/exceptions.py +26 -0
  36. tutor/generation/__init__.py +0 -0
  37. tutor/generation/assembler.py +81 -0
  38. tutor/generation/curriculum.py +97 -0
  39. tutor/generation/dialogue.py +172 -0
  40. tutor/generation/narrator.py +122 -0
  41. tutor/generation/segment_parser.py +223 -0
  42. tutor/generation/segment_planner.py +200 -0
  43. tutor/generation/visual_planner.py +205 -0
  44. tutor/infra/__init__.py +0 -0
  45. tutor/infra/llm.py +152 -0
  46. tutor/ingestion/__init__.py +0 -0
  47. tutor/ingestion/chunker.py +171 -0
  48. tutor/ingestion/doc_analyzer.py +41 -0
  49. tutor/ingestion/parse_content.py +19 -0
  50. tutor/ingestion/summarizer.py +51 -0
  51. tutor/inspector.py +117 -0
  52. tutor/llm_config.toml +58 -0
  53. tutor/models.py +147 -0
  54. tutor/player/__init__.py +0 -0
  55. tutor/player/input_handler.py +45 -0
  56. tutor/player/player.py +308 -0
  57. tutor/player/player_display.py +117 -0
  58. tutor/prompts/curriculum.txt +67 -0
  59. tutor/prompts/dialogue.txt +62 -0
  60. tutor/prompts/narrate.txt +34 -0
  61. tutor/prompts/qa.txt +17 -0
  62. tutor/prompts/summarize.txt +9 -0
  63. tutor/prompts/visual.txt +60 -0
  64. tutor/prompts/visual_v3.txt +91 -0
  65. tutor/qa/__init__.py +0 -0
  66. tutor/qa/qa.py +105 -0
  67. tutor/requirements-dev.txt +2 -0
  68. tutor/requirements.txt +12 -0
  69. tutor/sample_docs/headingless_large.md +1 -0
  70. tutor/sample_docs/headingless_test.md +1 -0
  71. tutor/sample_docs/java-basics.md +78 -0
  72. tutor/tests/__init__.py +0 -0
  73. tutor/tests/audio/__init__.py +0 -0
  74. tutor/tests/audio/test_audio_builder.py +106 -0
  75. tutor/tests/audio/test_sanitizer.py +41 -0
  76. tutor/tests/cli/__init__.py +0 -0
  77. tutor/tests/cli/test_commands.py +67 -0
  78. tutor/tests/cli/test_video_commands.py +190 -0
  79. tutor/tests/e2e/README.md +61 -0
  80. tutor/tests/e2e/__init__.py +0 -0
  81. tutor/tests/e2e/conftest.py +117 -0
  82. tutor/tests/e2e/fixtures/README.md +17 -0
  83. tutor/tests/e2e/fixtures/sample.md +13 -0
  84. tutor/tests/e2e/test_audio_quality.py +40 -0
  85. tutor/tests/e2e/test_av_sync.py +56 -0
  86. tutor/tests/e2e/test_pipeline_smoke.py +37 -0
  87. tutor/tests/e2e/test_slide_render.py +72 -0
  88. tutor/tests/e2e/test_video_streams.py +104 -0
  89. tutor/tests/generation/__init__.py +0 -0
  90. tutor/tests/generation/conftest.py +134 -0
  91. tutor/tests/generation/test_assembler.py +64 -0
  92. tutor/tests/generation/test_curriculum.py +107 -0
  93. tutor/tests/generation/test_narrator.py +165 -0
  94. tutor/tests/generation/test_segment_edge_cases.py +280 -0
  95. tutor/tests/generation/test_segment_planner.py +324 -0
  96. tutor/tests/generation/test_visual_planner.py +319 -0
  97. tutor/tests/ingestion/__init__.py +0 -0
  98. tutor/tests/ingestion/test_chunker.py +94 -0
  99. tutor/tests/ingestion/test_doc_analyzer.py +51 -0
  100. tutor/tests/player/__init__.py +0 -0
  101. tutor/tests/player/test_player_states.py +88 -0
  102. tutor/tests/test_assets.py +39 -0
  103. tutor/tests/test_models_visual.py +180 -0
  104. tutor/tests/visual/__init__.py +0 -0
  105. tutor/tests/visual/test_beat_timer.py +321 -0
  106. tutor/tests/visual/test_pipeline_integration.py +178 -0
  107. tutor/tests/visual/test_slide_renderer.py +298 -0
  108. tutor/tests/visual/test_subtitle_writer.py +165 -0
  109. tutor/tests/visual/test_video_assembler.py +108 -0
  110. tutor/tests/visual/test_visual_pipeline.py +270 -0
  111. tutor/tutor.py +365 -0
  112. tutor/visual/__init__.py +213 -0
  113. tutor/visual/beat_timer.py +222 -0
  114. tutor/visual/slide_renderer.py +236 -0
  115. tutor/visual/subtitle_writer.py +187 -0
  116. tutor/visual/templates/_base.html.j2 +40 -0
  117. tutor/visual/templates/analogy.html.j2 +21 -0
  118. tutor/visual/templates/callout.html.j2 +10 -0
  119. tutor/visual/templates/code_example.html.j2 +12 -0
  120. tutor/visual/templates/comparison.html.j2 +28 -0
  121. tutor/visual/templates/decision_guide.html.j2 +37 -0
  122. tutor/visual/templates/definition.html.j2 +13 -0
  123. tutor/visual/templates/diagram.html.j2 +11 -0
  124. tutor/visual/templates/hook_question.html.j2 +17 -0
  125. tutor/visual/templates/key_insight.html.j2 +9 -0
  126. tutor/visual/templates/memory_hook.html.j2 +7 -0
  127. tutor/visual/templates/outro.html.j2 +16 -0
  128. tutor/visual/templates/question_prompt.html.j2 +13 -0
  129. tutor/visual/templates/step_sequence.html.j2 +14 -0
  130. tutor/visual/templates/title_card.html.j2 +12 -0
  131. tutor/visual/video_assembler.py +299 -0
tutor/cli/commands.py ADDED
@@ -0,0 +1,391 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import datetime
5
+ import json
6
+ import logging
7
+ import os
8
+ from collections.abc import Callable
9
+ from pathlib import Path
10
+ from typing import Any, TypeAlias
11
+
12
+ from tutor.cli import theme
13
+ from tutor.cli.playback_commands import (
14
+ cmd_ask,
15
+ cmd_next,
16
+ cmd_pause,
17
+ cmd_play,
18
+ cmd_prev,
19
+ cmd_replay,
20
+ cmd_resume,
21
+ cmd_status,
22
+ cmd_stop,
23
+ cmd_summary,
24
+ )
25
+ from tutor.cli.shell_context import ShellContext # re-exported for callers
26
+
27
+ log = logging.getLogger(__name__)
28
+
29
+ AUDIO_DIR = Path("audio")
30
+
31
+ CommandFn: TypeAlias = Callable[[list[str], ShellContext], None]
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Helpers
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ def _parse_generate_args(tokens: list[str]) -> argparse.Namespace | None:
40
+ from tutor.tutor import _make_generate_parser
41
+
42
+ parser = _make_generate_parser()
43
+ try:
44
+ return parser.parse_args(tokens)
45
+ except SystemExit:
46
+ return None
47
+
48
+
49
+ def _apply_log_level(args: argparse.Namespace) -> None:
50
+ if getattr(args, "debug", False):
51
+ logging.getLogger().setLevel(logging.DEBUG)
52
+ elif getattr(args, "verbose", False):
53
+ logging.getLogger().setLevel(logging.INFO)
54
+
55
+
56
+ def _session_name(input_path: str) -> str:
57
+ """Derive a safe folder name from the input file path, e.g. week2/3.md → week2_3."""
58
+ return (
59
+ Path(input_path).with_suffix("").as_posix().replace("/", "_").replace("\\", "_").lstrip("_")
60
+ )
61
+
62
+
63
+ def _read_meta(path: Path) -> dict[str, Any]:
64
+ """Read tutorial.meta.json. Returns empty dict on any error."""
65
+ try:
66
+ result: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
67
+ return result
68
+ except Exception:
69
+ return {}
70
+
71
+
72
+ def _format_duration(seconds: Any) -> str:
73
+ """Convert seconds to M:SS string. Returns blank string if seconds <= 0."""
74
+ try:
75
+ secs = float(seconds)
76
+ except (TypeError, ValueError):
77
+ return ""
78
+ if secs <= 0:
79
+ return ""
80
+ m, s = divmod(int(secs), 60)
81
+ return f"{m}:{s:02d}"
82
+
83
+
84
+ def _write_session_meta(output: Path, input_path: str) -> None:
85
+ """Write tutorial.meta.json with source file, timestamp, and duration."""
86
+ duration_s = 0.0
87
+ full_mp3 = output.parent / "tutorial.mp3"
88
+ if full_mp3.exists():
89
+ try:
90
+ from pydub import AudioSegment
91
+
92
+ duration_s = len(AudioSegment.from_mp3(full_mp3)) / 1000.0
93
+ except Exception:
94
+ pass
95
+
96
+ meta = {
97
+ "source_file": input_path,
98
+ "generated_at": datetime.datetime.now().isoformat(timespec="seconds"),
99
+ "total_duration_s": duration_s,
100
+ }
101
+ (output.parent / "tutorial.meta.json").write_text(
102
+ json.dumps(meta, ensure_ascii=False), encoding="utf-8"
103
+ )
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Command handlers
108
+ # ---------------------------------------------------------------------------
109
+
110
+
111
+ def cmd_generate(tokens: list[str], ctx: ShellContext) -> None:
112
+ """Usage: /generate <file.md> [--duration N] [--difficulty LEVEL]
113
+ [--format FORMAT] [--topic TEXT] [--units N] [--no-cache]
114
+ [--script-only] [--dry-run] [--provider groq|openrouter]
115
+ [--verbose] [--debug]
116
+ Two expert hosts (ALEX and MAYA) walk through the document step by step.
117
+ Output is saved to audio/<session>/ automatically."""
118
+ if not tokens:
119
+ print(theme.red(" Error: /generate requires a file path."))
120
+ print(theme.dim(" Example: /generate notes.md --difficulty intermediate"))
121
+ return
122
+
123
+ args = _parse_generate_args(tokens)
124
+ if args is None:
125
+ print(theme.red(" Error: could not parse arguments."))
126
+ return
127
+
128
+ _apply_log_level(args)
129
+
130
+ if not any(t.startswith("--output") for t in tokens) and args.input:
131
+ session = _session_name(args.input)
132
+ if getattr(args, "explain", False):
133
+ session = session + "_explain"
134
+ args.output = str(AUDIO_DIR / session / "tutorial.mp3")
135
+
136
+ if (
137
+ args.input
138
+ and not getattr(args, "dry_run", False)
139
+ and not getattr(args, "inspect", False)
140
+ and not getattr(args, "script_only", False)
141
+ ):
142
+ Path(args.output).parent.mkdir(parents=True, exist_ok=True)
143
+
144
+ from tutor import tutor as _tutor
145
+ from tutor.exceptions import TutorError
146
+
147
+ try:
148
+ _tutor.cmd_generate(args)
149
+ output = Path(getattr(args, "output", "tutorial.mp3"))
150
+ ctx.last_units_dir = output.parent / "tutorial_units"
151
+ if (
152
+ not getattr(args, "dry_run", False)
153
+ and not getattr(args, "inspect", False)
154
+ and not getattr(args, "script_only", False)
155
+ ):
156
+ session = _session_name(args.input) if args.input else ""
157
+ if getattr(args, "explain", False):
158
+ session = session + "_explain"
159
+ ctx.current_session = session
160
+ if args.input:
161
+ _write_session_meta(output, str(args.input))
162
+ print(theme.green(f"\n Generation complete. Session: {theme.bold(session)}"))
163
+ print(theme.dim(f" Saved to: {output.parent}/"))
164
+ print(theme.green(" Type /play to start listening.\n"))
165
+ except TutorError as e:
166
+ print(theme.red(f"\n Error: {e}\n"))
167
+ except Exception as e:
168
+ log.exception("Unexpected error in /generate")
169
+ print(theme.red(f"\n Unexpected error: {e}\n"))
170
+
171
+
172
+ def cmd_sessions(tokens: list[str], ctx: ShellContext) -> None:
173
+ """Usage: /sessions — list all audio sessions"""
174
+ from tutor.cli.video_commands import VIDEO_DIR
175
+
176
+ if not AUDIO_DIR.exists():
177
+ print(theme.dim(" No sessions yet. Use /generate to create one."))
178
+ return
179
+
180
+ sessions = sorted(
181
+ d for d in AUDIO_DIR.iterdir() if d.is_dir() and (d / "tutorial_units").exists()
182
+ )
183
+ if not sessions:
184
+ print(theme.dim(" No sessions yet. Use /generate to create one."))
185
+ return
186
+
187
+ print()
188
+ for s in sessions:
189
+ units = list((s / "tutorial_units").glob("unit_*.mp3"))
190
+ meta = _read_meta(s / "tutorial.meta.json")
191
+ has_mp4 = (VIDEO_DIR / s.name / "full_session.mp4").exists()
192
+
193
+ dur_str = _format_duration(meta.get("total_duration_s", 0))
194
+ date_str = (meta.get("generated_at", "") or "")[:10]
195
+ badge = theme.green(" [video]") if has_mp4 else " "
196
+
197
+ print(
198
+ f" {theme.cyan(s.name):<22}"
199
+ f" {len(units):>2} units"
200
+ f" {dur_str:>6}"
201
+ f"{badge}"
202
+ f" {theme.dim(date_str)}"
203
+ )
204
+
205
+ print(theme.dim("\n Play: /play <name> Video: /video <name>"))
206
+ print()
207
+
208
+
209
+ def cmd_inspect(tokens: list[str], ctx: ShellContext) -> None:
210
+ """Usage: /inspect <file.md> [--show-summaries]"""
211
+ if not tokens:
212
+ print(theme.red(" Error: /inspect requires a file path."))
213
+ return
214
+ args = argparse.Namespace(
215
+ input=tokens[0],
216
+ provider="groq",
217
+ no_cache=False,
218
+ inspect=True,
219
+ show_summaries="--show-summaries" in tokens,
220
+ output="tutorial.mp3",
221
+ dry_run=False,
222
+ script_only=False,
223
+ play=False,
224
+ subject="java",
225
+ difficulty="beginner",
226
+ duration=20,
227
+ topic=None,
228
+ units=None,
229
+ fmt="tutor-student",
230
+ )
231
+ from tutor import tutor as _tutor
232
+ from tutor.exceptions import TutorError
233
+
234
+ try:
235
+ _tutor.cmd_generate(args)
236
+ except TutorError as e:
237
+ print(theme.red(f" Error: {e}"))
238
+
239
+
240
+ def cmd_dryrun(tokens: list[str], ctx: ShellContext) -> None:
241
+ """Usage: /dry-run <file.md> [--difficulty LEVEL] [--duration N] [--topic TEXT]"""
242
+ if not tokens:
243
+ print(theme.red(" Error: /dry-run requires a file path."))
244
+ return
245
+ args = _parse_generate_args([tokens[0], "--dry-run"] + tokens[1:])
246
+ if args is None:
247
+ return
248
+ from tutor import tutor as _tutor
249
+ from tutor.exceptions import TutorError
250
+
251
+ try:
252
+ _tutor.cmd_generate(args)
253
+ except TutorError as e:
254
+ print(theme.red(f" Error: {e}"))
255
+
256
+
257
+ def cmd_clear(tokens: list[str], ctx: ShellContext) -> None:
258
+ """Usage: /clear — clear the terminal"""
259
+ os.system("cls" if os.name == "nt" else "clear")
260
+
261
+
262
+ def cmd_help(tokens: list[str], ctx: ShellContext) -> None:
263
+ """Usage: /help [command] — list all commands, or show detail for one command"""
264
+ if tokens:
265
+ name = tokens[0].lstrip("/")
266
+ handler = COMMAND_MAP.get(f"/{name}") or COMMAND_MAP.get(name)
267
+ if handler and handler.__doc__:
268
+ print(f"\n {theme.cyan(f'/{name}')}")
269
+ for line in handler.__doc__.strip().splitlines():
270
+ print(f" {line}")
271
+ print()
272
+ return
273
+ print(theme.yellow(f" Unknown command: /{name}"))
274
+ return
275
+
276
+ lines = f"""
277
+ {theme.bold("─── AUDIO PIPELINE ─────────────────────────────────────────────────────────")}
278
+
279
+ {theme.cyan("/generate")} <file.md> [flags] Parse notes → generate dialogue → synthesise MP3s
280
+ {theme.cyan("/sessions")} List all audio sessions in audio/
281
+ {theme.cyan("/inspect")} <file.md> Show ingestion report without running anything
282
+ {theme.cyan("/dry-run")} <file.md> [flags] Preview curriculum plan; skip dialogue and audio
283
+
284
+ {theme.bold("/generate flags:")}
285
+ --explain Read-along mode: narrate document top-to-bottom (one unit per section)
286
+ --conversation Expert dialogue mode: ALEX and MAYA explain the document step by step (default)
287
+ --duration N Target length in minutes (default: 20, conversation only)
288
+ --difficulty LEVEL beginner | intermediate | advanced (default: beginner, conversation only)
289
+ --format FORMAT tutor-student | dual-tutor (default: tutor-student, conversation only)
290
+ tutor-student: ALEX (male) + MAYA (female) | dual-tutor: ALEX + SAM (both male)
291
+ --topic TEXT Force a specific concept into the curriculum (conversation only)
292
+ --units N Cap the number of teaching units (conversation only)
293
+ --provider NAME groq | openrouter (default: groq)
294
+ --no-cache Ignore cached narrations/dialogues and regenerate
295
+ --script-only Print script only; skip audio synthesis
296
+ --verbose Show per-step progress logs
297
+ --debug Write DEBUG logs to tutor.log
298
+
299
+ {theme.bold("─── AUDIO PLAYBACK ──────────────────────────────────────────────────────────")}
300
+
301
+ {theme.cyan("/play")} [session | path] Load and play a session (MP3 units)
302
+ {theme.cyan("/pause")} Pause playback
303
+ {theme.cyan("/resume")} Resume from pause
304
+ {theme.cyan("/stop")} Stop and unload the player
305
+ {theme.cyan("/next")} Skip to next unit
306
+ {theme.cyan("/prev")} Go back to previous unit
307
+ {theme.cyan("/replay")} Restart the current unit from the beginning
308
+ {theme.cyan("/status")} Show player state, current unit, time, Q&A count
309
+ {theme.cyan("/ask")} [question] Ask a question about the current unit (LLM-powered)
310
+ {theme.cyan("/summary")} Print the current unit's summary and memory hook
311
+
312
+ {theme.bold("─── VIDEO PIPELINE ──────────────────────────────────────────────────────────")}
313
+
314
+ {theme.cyan("/video")} [session] Render slides + subtitles → assemble MP4
315
+ Requires a completed /generate session.
316
+ Output → video/<session>/full_session.mp4
317
+ {theme.cyan("/vsessions")} List sessions that have a completed video
318
+
319
+ {theme.bold("─── SHELL ───────────────────────────────────────────────────────────────────")}
320
+
321
+ {theme.cyan("/help")} [command] Show this help, or detail for one command
322
+ {theme.cyan("/clear")} Clear the terminal
323
+ {theme.cyan("/quit")} Exit LearnX
324
+
325
+ {theme.bold("─── EXAMPLES ────────────────────────────────────────────────────────────────")}
326
+
327
+ {theme.bold("1. Generate and listen (expert dialogue):")}
328
+ /generate week3/1.md --provider openrouter
329
+ /play week3_1 (load the session)
330
+ /next (move to next concept)
331
+ /ask why does == fail for Strings? (ask anything mid-listen)
332
+ /summary (print the memory hook for this unit)
333
+ /replay (restart current unit)
334
+ /stop
335
+
336
+ {theme.bold("2. Adjust difficulty or focus:")}
337
+ /generate week3/1.md --difficulty intermediate --provider openrouter
338
+ /generate week3/1.md --topic "HashMap internals" --provider openrouter
339
+ /generate week3/1.md --units 3 --provider openrouter
340
+
341
+ {theme.bold("3. Two male expert hosts instead of ALEX + MAYA:")}
342
+ /generate week3/1.md --format dual-tutor --provider openrouter
343
+
344
+ {theme.bold("4. Preview before generating (no LLM calls for audio):")}
345
+ /inspect week3/1.md (show chunk breakdown)
346
+ /dry-run week3/1.md (show planned units, no audio)
347
+
348
+ {theme.bold("5. Read-along (ALEX narrates the document top to bottom):")}
349
+ /generate week3/1.md --explain
350
+ /play week3_1_explain
351
+
352
+ {theme.bold("6. Render a video from an existing session:")}
353
+ /generate week3/1.md --provider openrouter (generate audio first)
354
+ /video week3_1 (render slides + subtitles → MP4)
355
+ /vsessions (list completed videos)
356
+
357
+ {theme.bold("7. See all sessions:")}
358
+ /sessions
359
+ """
360
+ print(lines)
361
+
362
+
363
+ # ---------------------------------------------------------------------------
364
+ # Dispatch table
365
+ # ---------------------------------------------------------------------------
366
+
367
+ COMMAND_MAP: dict[str, CommandFn | None] = {
368
+ "/generate": cmd_generate,
369
+ "/gen": cmd_generate,
370
+ "/sessions": cmd_sessions,
371
+ "/play": cmd_play,
372
+ "/pause": cmd_pause,
373
+ "/resume": cmd_resume,
374
+ "/stop": cmd_stop,
375
+ "/next": cmd_next,
376
+ "/prev": cmd_prev,
377
+ "/back": cmd_prev,
378
+ "/replay": cmd_replay,
379
+ "/ask": cmd_ask,
380
+ "/summary": cmd_summary,
381
+ "/status": cmd_status,
382
+ "/inspect": cmd_inspect,
383
+ "/dry-run": cmd_dryrun,
384
+ "/dryrun": cmd_dryrun,
385
+ "/clear": cmd_clear,
386
+ "/help": cmd_help,
387
+ "/?": cmd_help,
388
+ "/quit": None,
389
+ "/exit": None,
390
+ "/q": None,
391
+ }
tutor/cli/logo.py ADDED
@@ -0,0 +1,21 @@
1
+ from tutor.cli import theme
2
+
3
+ LOGO = r"""
4
+ ██╗ ███████╗ █████╗ ██████╗ ███╗ ██╗██╗ ██╗
5
+ ██║ ██╔════╝██╔══██╗██╔══██╗████╗ ██║╚██╗██╔╝
6
+ ██║ █████╗ ███████║██████╔╝██╔██╗ ██║ ╚███╔╝
7
+ ██║ ██╔══╝ ██╔══██║██╔══██╗██║╚██╗██║ ██╔██╗
8
+ ███████╗███████╗██║ ██║██║ ██║██║ ╚████║██╔╝ ██╗
9
+ ╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝
10
+ """
11
+
12
+ TAGLINE = "Audio tutorials from any Markdown document"
13
+ VERSION = "v1.0"
14
+ DIVIDER = "─" * 54
15
+
16
+
17
+ def print_welcome() -> None:
18
+ print(theme.CYAN + LOGO + theme.RESET, end="")
19
+ print(f" {theme.bold(TAGLINE)} {theme.dim(VERSION)}")
20
+ print(theme.dim(f" {DIVIDER}"))
21
+ print(theme.dim(" Type /help to see available commands.\n"))
@@ -0,0 +1,239 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+ import threading
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from tutor.cli import theme
10
+ from tutor.cli.shell_context import ShellContext
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+ AUDIO_DIR = Path("audio")
15
+
16
+
17
+ def _require_player(ctx: ShellContext, require_unit: bool = False) -> bool:
18
+ if not ctx.player:
19
+ print(theme.red(" No active player. Use /play first."))
20
+ return False
21
+ if ctx.player._state == "STOPPED":
22
+ print(theme.red(" Player has stopped. Use /play to start a new session."))
23
+ return False
24
+ if require_unit and ctx.player._current_idx >= len(ctx.player.units):
25
+ print(theme.red(" No unit loaded."))
26
+ return False
27
+ return True
28
+
29
+
30
+ def _get_flag(tokens: list[str], flag: str, default: str) -> str:
31
+ try:
32
+ idx = tokens.index(flag)
33
+ return tokens[idx + 1]
34
+ except (ValueError, IndexError):
35
+ return default
36
+
37
+
38
+ def _resolve_units_dir(token: str) -> Path | None:
39
+ """Resolve a token to a tutorial_units directory.
40
+
41
+ Accepts:
42
+ - a session name (e.g. "week2_3") → audio/week2_3/tutorial_units/
43
+ - a direct path to tutorial_units/ → used as-is
44
+ - a path to any file inside audio/ → parent/tutorial_units/
45
+ """
46
+ p = Path(token)
47
+
48
+ if p.exists():
49
+ return p if p.is_dir() else p.parent / "tutorial_units"
50
+
51
+ candidate = AUDIO_DIR / token / "tutorial_units"
52
+ if candidate.exists():
53
+ return candidate
54
+
55
+ return None
56
+
57
+
58
+ def cmd_play(tokens: list[str], ctx: ShellContext) -> None:
59
+ """Usage: /play [session-name | path] [--no-qa] [--provider groq|openrouter]
60
+ session-name: folder name under audio/ (see /sessions)"""
61
+ unit_token = next((t for t in tokens if not t.startswith("--")), None)
62
+ if unit_token:
63
+ units_dir = _resolve_units_dir(unit_token)
64
+ if units_dir is None:
65
+ print(theme.red(f" Session '{unit_token}' not found."))
66
+ print(theme.dim(" Use /sessions to list available sessions."))
67
+ return
68
+ elif ctx.last_units_dir and ctx.last_units_dir.exists():
69
+ units_dir = ctx.last_units_dir
70
+ else:
71
+ print(theme.red(" No session known. Run /generate first or pass a session name."))
72
+ print(theme.dim(" Use /sessions to list available sessions."))
73
+ return
74
+
75
+ if ctx.player and ctx.player._state == "PAUSED":
76
+ ctx.player._resume()
77
+ print(theme.green(" Resumed."))
78
+ return
79
+
80
+ if ctx.player and ctx.player._state == "PLAYING":
81
+ print(theme.yellow(" Already playing. Use /pause, /next, /stop."))
82
+ return
83
+
84
+ if ctx.player and ctx.player._state == "STOPPED":
85
+ if ctx.player_thread:
86
+ ctx.player_thread.join(timeout=1.0)
87
+ ctx.player = None
88
+ ctx.player_thread = None
89
+
90
+ from tutor.exceptions import TutorError
91
+ from tutor.tutor import _build_player
92
+
93
+ play_args = argparse.Namespace(
94
+ audio_file=str(units_dir),
95
+ provider=_get_flag(tokens, "--provider", "groq"),
96
+ no_qa="--no-qa" in tokens,
97
+ )
98
+ try:
99
+ player = _build_player(play_args)
100
+ except TutorError as e:
101
+ print(theme.red(f" Error: {e}"))
102
+ return
103
+
104
+ ctx.player = player
105
+ ctx.last_units_dir = units_dir
106
+
107
+ def _run() -> None:
108
+ player.run_in_shell()
109
+
110
+ ctx.player_thread = threading.Thread(target=_run, daemon=True, name="PlayerThread")
111
+ ctx.player_thread.start()
112
+ print(theme.green(f" Playing: {units_dir}"))
113
+ print(theme.dim(" Controls: /pause /next /prev /stop /ask /summary"))
114
+
115
+
116
+ def cmd_pause(tokens: list[str], ctx: ShellContext) -> None:
117
+ """Usage: /pause — pause playback"""
118
+ if not _require_player(ctx):
119
+ return
120
+ assert ctx.player is not None
121
+ if ctx.player._state != "PLAYING":
122
+ print(theme.yellow(" Not currently playing."))
123
+ return
124
+ ctx.player._pause()
125
+ print(theme.cyan(" Paused."))
126
+
127
+
128
+ def cmd_resume(tokens: list[str], ctx: ShellContext) -> None:
129
+ """Usage: /resume — resume playback"""
130
+ if not _require_player(ctx):
131
+ return
132
+ assert ctx.player is not None
133
+ if ctx.player._state != "PAUSED":
134
+ print(theme.yellow(" Not paused."))
135
+ return
136
+ ctx.player._resume()
137
+ print(theme.green(" Resumed."))
138
+
139
+
140
+ def cmd_stop(tokens: list[str], ctx: ShellContext) -> None:
141
+ """Usage: /stop — stop playback and unload the player"""
142
+ if not _require_player(ctx):
143
+ return
144
+ assert ctx.player is not None
145
+ ctx.player._quit()
146
+ if ctx.player_thread:
147
+ ctx.player_thread.join(timeout=3.0)
148
+ ctx.player = None
149
+ ctx.player_thread = None
150
+ print(theme.cyan(" Stopped."))
151
+
152
+
153
+ def cmd_next(tokens: list[str], ctx: ShellContext) -> None:
154
+ """Usage: /next — jump to the next unit"""
155
+ if not _require_player(ctx):
156
+ return
157
+ assert ctx.player is not None
158
+ ctx.player._next_unit()
159
+
160
+
161
+ def cmd_prev(tokens: list[str], ctx: ShellContext) -> None:
162
+ """Usage: /prev — jump to the previous unit"""
163
+ if not _require_player(ctx):
164
+ return
165
+ assert ctx.player is not None
166
+ ctx.player._prev_unit()
167
+
168
+
169
+ def cmd_replay(tokens: list[str], ctx: ShellContext) -> None:
170
+ """Usage: /replay — replay the current unit from the beginning"""
171
+ if not _require_player(ctx):
172
+ return
173
+ assert ctx.player is not None
174
+ ctx.player._replay_unit()
175
+
176
+
177
+ def cmd_ask(tokens: list[str], ctx: ShellContext) -> None:
178
+ """Usage: /ask [question text]
179
+ If question is provided inline, skips the prompt. Pauses audio while answering."""
180
+ if not _require_player(ctx, require_unit=True):
181
+ return
182
+ assert ctx.player is not None
183
+
184
+ was_playing = ctx.player._state == "PLAYING"
185
+ if was_playing:
186
+ ctx.player._pause()
187
+ time.sleep(0.05)
188
+
189
+ question = " ".join(tokens).strip() if tokens else None
190
+ if not question:
191
+ try:
192
+ question = input(theme.cyan(" Your question: ")).strip()
193
+ except (KeyboardInterrupt, EOFError):
194
+ print()
195
+ if was_playing:
196
+ ctx.player._resume()
197
+ return
198
+
199
+ if not question:
200
+ if was_playing:
201
+ ctx.player._resume()
202
+ return
203
+
204
+ ctx.player._ask_question_from_shell(question)
205
+
206
+
207
+ def cmd_summary(tokens: list[str], ctx: ShellContext) -> None:
208
+ """Usage: /summary — print the current unit summary and memory hook"""
209
+ if not _require_player(ctx, require_unit=True):
210
+ return
211
+ assert ctx.player is not None
212
+ ctx.player._print_summary()
213
+
214
+
215
+ def cmd_status(tokens: list[str], ctx: ShellContext) -> None:
216
+ """Usage: /status — show player state, current unit, elapsed time, Q&A count"""
217
+ if not ctx.player:
218
+ print(theme.dim(" No active session."))
219
+ return
220
+ p = ctx.player
221
+ state_icons = {
222
+ "PLAYING": theme.green("▶ Playing"),
223
+ "PAUSED": theme.yellow("⏸ Paused"),
224
+ "STOPPED": theme.dim("■ Stopped"),
225
+ "ASKING": theme.cyan("? Asking"),
226
+ "ANSWERING": theme.cyan("⟳ Answering"),
227
+ }
228
+ state_str = state_icons.get(p._state, p._state)
229
+ unit = p.units[p._current_idx] if p._current_idx < len(p.units) else None
230
+ unit_str = f"Unit {p._current_idx + 1}/{len(p.units)} — {unit.concept}" if unit else "—"
231
+ elapsed = p._elapsed_seconds()
232
+ total = p._unit_duration_s()
233
+ m_el, s_el = divmod(elapsed, 60)
234
+ m_to, s_to = divmod(total, 60)
235
+
236
+ print(f"\n State: {state_str}")
237
+ print(f" Unit: {unit_str}")
238
+ print(f" Time: {m_el:02d}:{s_el:02d} / {m_to:02d}:{s_to:02d}")
239
+ print(f" Q&A: {p.qa_count} question(s) this session\n")