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.
- learnx_cli-0.3.0.dist-info/METADATA +240 -0
- learnx_cli-0.3.0.dist-info/RECORD +131 -0
- learnx_cli-0.3.0.dist-info/WHEEL +4 -0
- learnx_cli-0.3.0.dist-info/entry_points.txt +2 -0
- tutor/.env copy.example +4 -0
- tutor/__init__.py +0 -0
- tutor/__main__.py +4 -0
- tutor/assets/__init__.py +5 -0
- tutor/assets/html/fonts/Inter-Bold.woff2 +0 -0
- tutor/assets/html/fonts/Inter-Regular.woff2 +0 -0
- tutor/assets/html/fonts/Inter-SemiBold.woff2 +0 -0
- tutor/assets/html/fonts/JetBrainsMono-Regular.woff2 +0 -0
- tutor/assets/html/highlight-java.min.js +2 -0
- tutor/assets/html/highlight-javascript.min.js +2 -0
- tutor/assets/html/highlight-python.min.js +2 -0
- tutor/assets/html/highlight.min.js +17 -0
- tutor/assets/html/mermaid.min.js +31 -0
- tutor/assets/html/slide_base.css +464 -0
- tutor/assets/html/theme-learnx-dark.css +12 -0
- tutor/audio/__init__.py +0 -0
- tutor/audio/audio_builder.py +143 -0
- tutor/audio/sanitizer.py +9 -0
- tutor/audio/tts_renderer.py +54 -0
- tutor/cli/__init__.py +0 -0
- tutor/cli/commands.py +391 -0
- tutor/cli/logo.py +21 -0
- tutor/cli/playback_commands.py +239 -0
- tutor/cli/shell.py +91 -0
- tutor/cli/shell_context.py +18 -0
- tutor/cli/theme.py +39 -0
- tutor/cli/video_commands.py +123 -0
- tutor/config.py +122 -0
- tutor/conftest.py +5 -0
- tutor/constants.py +82 -0
- tutor/exceptions.py +26 -0
- tutor/generation/__init__.py +0 -0
- tutor/generation/assembler.py +81 -0
- tutor/generation/curriculum.py +97 -0
- tutor/generation/dialogue.py +172 -0
- tutor/generation/narrator.py +122 -0
- tutor/generation/segment_parser.py +223 -0
- tutor/generation/segment_planner.py +200 -0
- tutor/generation/visual_planner.py +205 -0
- tutor/infra/__init__.py +0 -0
- tutor/infra/llm.py +152 -0
- tutor/ingestion/__init__.py +0 -0
- tutor/ingestion/chunker.py +171 -0
- tutor/ingestion/doc_analyzer.py +41 -0
- tutor/ingestion/parse_content.py +19 -0
- tutor/ingestion/summarizer.py +51 -0
- tutor/inspector.py +117 -0
- tutor/llm_config.toml +58 -0
- tutor/models.py +147 -0
- tutor/player/__init__.py +0 -0
- tutor/player/input_handler.py +45 -0
- tutor/player/player.py +308 -0
- tutor/player/player_display.py +117 -0
- tutor/prompts/curriculum.txt +67 -0
- tutor/prompts/dialogue.txt +62 -0
- tutor/prompts/narrate.txt +34 -0
- tutor/prompts/qa.txt +17 -0
- tutor/prompts/summarize.txt +9 -0
- tutor/prompts/visual.txt +60 -0
- tutor/prompts/visual_v3.txt +91 -0
- tutor/qa/__init__.py +0 -0
- tutor/qa/qa.py +105 -0
- tutor/requirements-dev.txt +2 -0
- tutor/requirements.txt +12 -0
- tutor/sample_docs/headingless_large.md +1 -0
- tutor/sample_docs/headingless_test.md +1 -0
- tutor/sample_docs/java-basics.md +78 -0
- tutor/tests/__init__.py +0 -0
- tutor/tests/audio/__init__.py +0 -0
- tutor/tests/audio/test_audio_builder.py +106 -0
- tutor/tests/audio/test_sanitizer.py +41 -0
- tutor/tests/cli/__init__.py +0 -0
- tutor/tests/cli/test_commands.py +67 -0
- tutor/tests/cli/test_video_commands.py +190 -0
- tutor/tests/e2e/README.md +61 -0
- tutor/tests/e2e/__init__.py +0 -0
- tutor/tests/e2e/conftest.py +117 -0
- tutor/tests/e2e/fixtures/README.md +17 -0
- tutor/tests/e2e/fixtures/sample.md +13 -0
- tutor/tests/e2e/test_audio_quality.py +40 -0
- tutor/tests/e2e/test_av_sync.py +56 -0
- tutor/tests/e2e/test_pipeline_smoke.py +37 -0
- tutor/tests/e2e/test_slide_render.py +72 -0
- tutor/tests/e2e/test_video_streams.py +104 -0
- tutor/tests/generation/__init__.py +0 -0
- tutor/tests/generation/conftest.py +134 -0
- tutor/tests/generation/test_assembler.py +64 -0
- tutor/tests/generation/test_curriculum.py +107 -0
- tutor/tests/generation/test_narrator.py +165 -0
- tutor/tests/generation/test_segment_edge_cases.py +280 -0
- tutor/tests/generation/test_segment_planner.py +324 -0
- tutor/tests/generation/test_visual_planner.py +319 -0
- tutor/tests/ingestion/__init__.py +0 -0
- tutor/tests/ingestion/test_chunker.py +94 -0
- tutor/tests/ingestion/test_doc_analyzer.py +51 -0
- tutor/tests/player/__init__.py +0 -0
- tutor/tests/player/test_player_states.py +88 -0
- tutor/tests/test_assets.py +39 -0
- tutor/tests/test_models_visual.py +180 -0
- tutor/tests/visual/__init__.py +0 -0
- tutor/tests/visual/test_beat_timer.py +321 -0
- tutor/tests/visual/test_pipeline_integration.py +178 -0
- tutor/tests/visual/test_slide_renderer.py +298 -0
- tutor/tests/visual/test_subtitle_writer.py +165 -0
- tutor/tests/visual/test_video_assembler.py +108 -0
- tutor/tests/visual/test_visual_pipeline.py +270 -0
- tutor/tutor.py +365 -0
- tutor/visual/__init__.py +213 -0
- tutor/visual/beat_timer.py +222 -0
- tutor/visual/slide_renderer.py +236 -0
- tutor/visual/subtitle_writer.py +187 -0
- tutor/visual/templates/_base.html.j2 +40 -0
- tutor/visual/templates/analogy.html.j2 +21 -0
- tutor/visual/templates/callout.html.j2 +10 -0
- tutor/visual/templates/code_example.html.j2 +12 -0
- tutor/visual/templates/comparison.html.j2 +28 -0
- tutor/visual/templates/decision_guide.html.j2 +37 -0
- tutor/visual/templates/definition.html.j2 +13 -0
- tutor/visual/templates/diagram.html.j2 +11 -0
- tutor/visual/templates/hook_question.html.j2 +17 -0
- tutor/visual/templates/key_insight.html.j2 +9 -0
- tutor/visual/templates/memory_hook.html.j2 +7 -0
- tutor/visual/templates/outro.html.j2 +16 -0
- tutor/visual/templates/question_prompt.html.j2 +13 -0
- tutor/visual/templates/step_sequence.html.j2 +14 -0
- tutor/visual/templates/title_card.html.j2 +12 -0
- 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")
|