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/player/player.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING, Literal
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from tutor.infra.llm import LLMFn
|
|
10
|
+
|
|
11
|
+
import pygame
|
|
12
|
+
|
|
13
|
+
from tutor.constants import PLAYER_POLL_HZ
|
|
14
|
+
from tutor.models import Chunk, SessionLog, TeachingUnit
|
|
15
|
+
from tutor.player import input_handler, player_display
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
PlayerState = Literal["PLAYING", "PAUSED", "ASKING", "ANSWERING", "STOPPED"]
|
|
20
|
+
|
|
21
|
+
MUSIC_END = pygame.USEREVENT + 1
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class TutorPlayer:
|
|
26
|
+
unit_files: list[str]
|
|
27
|
+
units: list[TeachingUnit]
|
|
28
|
+
chunks: list[Chunk] = field(default_factory=list)
|
|
29
|
+
session: SessionLog | None = None
|
|
30
|
+
llm_fn: "LLMFn | None" = None
|
|
31
|
+
no_qa: bool = False
|
|
32
|
+
qa_count: int = 0
|
|
33
|
+
_state: PlayerState = field(default="PAUSED", init=False)
|
|
34
|
+
_current_idx: int = field(default=0, init=False)
|
|
35
|
+
_start_time: float = field(default=0.0, init=False)
|
|
36
|
+
_pause_start: float = field(default=0.0, init=False)
|
|
37
|
+
_current_unit_duration_s: int = field(default=0, init=False)
|
|
38
|
+
_duration_cache: dict[str, int] = field(default_factory=dict, init=False)
|
|
39
|
+
|
|
40
|
+
def run(self) -> None:
|
|
41
|
+
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
|
|
42
|
+
pygame.init()
|
|
43
|
+
pygame.mixer.init()
|
|
44
|
+
pygame.mixer.music.set_endevent(MUSIC_END)
|
|
45
|
+
|
|
46
|
+
self._load_unit(0)
|
|
47
|
+
self._play()
|
|
48
|
+
|
|
49
|
+
poll_interval = 1.0 / PLAYER_POLL_HZ
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
while self._state != "STOPPED":
|
|
53
|
+
self._handle_events()
|
|
54
|
+
self._handle_keys()
|
|
55
|
+
self._redraw()
|
|
56
|
+
time.sleep(poll_interval)
|
|
57
|
+
except KeyboardInterrupt:
|
|
58
|
+
pass
|
|
59
|
+
finally:
|
|
60
|
+
pygame.mixer.quit()
|
|
61
|
+
pygame.quit()
|
|
62
|
+
|
|
63
|
+
def _play(self) -> None:
|
|
64
|
+
pygame.mixer.music.play()
|
|
65
|
+
self._state = "PLAYING"
|
|
66
|
+
self._start_time = time.time()
|
|
67
|
+
|
|
68
|
+
def _pause(self) -> None:
|
|
69
|
+
pygame.mixer.music.pause()
|
|
70
|
+
self._pause_start = time.time()
|
|
71
|
+
self._state = "PAUSED"
|
|
72
|
+
|
|
73
|
+
def _resume(self) -> None:
|
|
74
|
+
pygame.mixer.music.unpause()
|
|
75
|
+
paused_for = time.time() - self._pause_start
|
|
76
|
+
self._start_time += paused_for
|
|
77
|
+
self._state = "PLAYING"
|
|
78
|
+
|
|
79
|
+
def _load_unit(self, idx: int) -> None:
|
|
80
|
+
if idx >= len(self.unit_files):
|
|
81
|
+
self._on_session_complete()
|
|
82
|
+
return
|
|
83
|
+
self._current_idx = idx
|
|
84
|
+
path = self.unit_files[idx]
|
|
85
|
+
pygame.mixer.music.load(path)
|
|
86
|
+
if path not in self._duration_cache:
|
|
87
|
+
try:
|
|
88
|
+
from pydub import AudioSegment
|
|
89
|
+
|
|
90
|
+
audio = AudioSegment.from_mp3(path)
|
|
91
|
+
self._duration_cache[path] = len(audio) // 1000
|
|
92
|
+
except Exception:
|
|
93
|
+
self._duration_cache[path] = 0
|
|
94
|
+
self._current_unit_duration_s = self._duration_cache[path]
|
|
95
|
+
log.info("Loaded unit %d: %s", idx, path)
|
|
96
|
+
|
|
97
|
+
def _handle_events(self) -> None:
|
|
98
|
+
for event in pygame.event.get():
|
|
99
|
+
if event.type == MUSIC_END and self._state == "PLAYING":
|
|
100
|
+
next_idx = self._current_idx + 1
|
|
101
|
+
if next_idx < len(self.unit_files):
|
|
102
|
+
self._load_unit(next_idx)
|
|
103
|
+
self._play()
|
|
104
|
+
else:
|
|
105
|
+
self._on_session_complete()
|
|
106
|
+
|
|
107
|
+
def _handle_keys(self) -> None:
|
|
108
|
+
if self._state in ("ASKING", "ANSWERING"):
|
|
109
|
+
return
|
|
110
|
+
key = input_handler.get_key()
|
|
111
|
+
if key is None:
|
|
112
|
+
return
|
|
113
|
+
dispatch: dict[str, Callable[[], None]] = {
|
|
114
|
+
" ": self._toggle_play_pause,
|
|
115
|
+
"p": self._toggle_play_pause,
|
|
116
|
+
"n": self._next_unit,
|
|
117
|
+
"b": self._prev_unit,
|
|
118
|
+
"r": self._replay_unit,
|
|
119
|
+
"s": self._print_summary,
|
|
120
|
+
"q": self._quit,
|
|
121
|
+
"?": self._ask_question,
|
|
122
|
+
}
|
|
123
|
+
action = dispatch.get(key)
|
|
124
|
+
if action:
|
|
125
|
+
action()
|
|
126
|
+
|
|
127
|
+
def _toggle_play_pause(self) -> None:
|
|
128
|
+
if self._state == "PLAYING":
|
|
129
|
+
self._pause()
|
|
130
|
+
elif self._state == "PAUSED":
|
|
131
|
+
self._resume()
|
|
132
|
+
|
|
133
|
+
def _next_unit(self) -> None:
|
|
134
|
+
was_playing = self._state == "PLAYING"
|
|
135
|
+
pygame.mixer.music.stop()
|
|
136
|
+
next_idx = min(self._current_idx + 1, len(self.unit_files) - 1)
|
|
137
|
+
self._load_unit(next_idx)
|
|
138
|
+
if was_playing:
|
|
139
|
+
self._play()
|
|
140
|
+
|
|
141
|
+
def _prev_unit(self) -> None:
|
|
142
|
+
was_playing = self._state == "PLAYING"
|
|
143
|
+
pygame.mixer.music.stop()
|
|
144
|
+
prev_idx = max(self._current_idx - 1, 0)
|
|
145
|
+
self._load_unit(prev_idx)
|
|
146
|
+
if was_playing:
|
|
147
|
+
self._play()
|
|
148
|
+
|
|
149
|
+
def _replay_unit(self) -> None:
|
|
150
|
+
pygame.mixer.music.stop()
|
|
151
|
+
self._load_unit(self._current_idx)
|
|
152
|
+
self._play()
|
|
153
|
+
|
|
154
|
+
def _print_summary(self) -> None:
|
|
155
|
+
if self._current_idx < len(self.units):
|
|
156
|
+
player_display.print_summary(self.units[self._current_idx])
|
|
157
|
+
|
|
158
|
+
def _quit(self) -> None:
|
|
159
|
+
pygame.mixer.music.stop()
|
|
160
|
+
self._state = "STOPPED"
|
|
161
|
+
|
|
162
|
+
def _ask_question(self) -> None:
|
|
163
|
+
if self.no_qa or self.llm_fn is None:
|
|
164
|
+
player_display.print_qa_disabled()
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
if self._state == "PLAYING":
|
|
168
|
+
self._pause()
|
|
169
|
+
|
|
170
|
+
self._state = "ASKING"
|
|
171
|
+
player_display.clear_status()
|
|
172
|
+
question = self._prompt_for_question()
|
|
173
|
+
|
|
174
|
+
if question is None:
|
|
175
|
+
self._state = "PAUSED"
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
self._state = "ANSWERING"
|
|
179
|
+
player_display.print_thinking()
|
|
180
|
+
|
|
181
|
+
from tutor.qa import qa
|
|
182
|
+
|
|
183
|
+
current_unit = (
|
|
184
|
+
self.units[self._current_idx] if self._current_idx < len(self.units) else None
|
|
185
|
+
)
|
|
186
|
+
if current_unit is None:
|
|
187
|
+
player_display.print_no_context()
|
|
188
|
+
self._state = "PAUSED"
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
assert self.session is not None
|
|
192
|
+
answer_text = qa.answer(
|
|
193
|
+
question=question,
|
|
194
|
+
current_unit=current_unit,
|
|
195
|
+
all_chunks=self.chunks,
|
|
196
|
+
session=self.session,
|
|
197
|
+
llm_fn=self.llm_fn,
|
|
198
|
+
position_seconds=self._elapsed_seconds(),
|
|
199
|
+
)
|
|
200
|
+
self.qa_count += 1
|
|
201
|
+
|
|
202
|
+
player_display.print_answer(answer_text, current_unit.concept)
|
|
203
|
+
self._state = "PAUSED"
|
|
204
|
+
player_display.print_resume_hint()
|
|
205
|
+
|
|
206
|
+
def _prompt_for_question(self) -> str | None:
|
|
207
|
+
unit = self.units[self._current_idx] if self._current_idx < len(self.units) else None
|
|
208
|
+
topic = unit.concept if unit else "current topic"
|
|
209
|
+
player_display.print_question_header(
|
|
210
|
+
topic, player_display._fmt_time(self._elapsed_seconds())
|
|
211
|
+
)
|
|
212
|
+
try:
|
|
213
|
+
return input("Your question: ").strip() or None
|
|
214
|
+
except (KeyboardInterrupt, EOFError):
|
|
215
|
+
player_display.print_cancelled()
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
def _redraw(self) -> None:
|
|
219
|
+
if self._state == "STOPPED" or self._current_idx >= len(self.units):
|
|
220
|
+
return
|
|
221
|
+
unit = self.units[self._current_idx]
|
|
222
|
+
elapsed_s = self._elapsed_seconds()
|
|
223
|
+
total_s = self._unit_duration_s()
|
|
224
|
+
player_display.render_status(
|
|
225
|
+
unit=unit,
|
|
226
|
+
unit_idx=self._current_idx + 1,
|
|
227
|
+
total_units=len(self.units),
|
|
228
|
+
elapsed_s=elapsed_s,
|
|
229
|
+
total_s=total_s,
|
|
230
|
+
state=self._state,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def _elapsed_seconds(self) -> int:
|
|
234
|
+
if self._state == "PAUSED":
|
|
235
|
+
return int(self._pause_start - self._start_time)
|
|
236
|
+
if self._state == "PLAYING":
|
|
237
|
+
return int(time.time() - self._start_time)
|
|
238
|
+
return 0
|
|
239
|
+
|
|
240
|
+
def _unit_duration_s(self) -> int:
|
|
241
|
+
return self._current_unit_duration_s
|
|
242
|
+
|
|
243
|
+
def _get_duration(self, filepath: str) -> int:
|
|
244
|
+
return self._duration_cache.get(filepath, 0)
|
|
245
|
+
|
|
246
|
+
def run_in_shell(self) -> None:
|
|
247
|
+
"""Headless playback loop for the interactive shell — no keyboard, no status bar."""
|
|
248
|
+
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
|
|
249
|
+
pygame.init()
|
|
250
|
+
pygame.mixer.init()
|
|
251
|
+
pygame.mixer.music.set_endevent(MUSIC_END)
|
|
252
|
+
|
|
253
|
+
self._load_unit(0)
|
|
254
|
+
self._play()
|
|
255
|
+
|
|
256
|
+
poll_interval = 1.0 / PLAYER_POLL_HZ
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
while self._state != "STOPPED":
|
|
260
|
+
self._handle_events()
|
|
261
|
+
time.sleep(poll_interval)
|
|
262
|
+
finally:
|
|
263
|
+
pygame.mixer.quit()
|
|
264
|
+
pygame.quit()
|
|
265
|
+
|
|
266
|
+
def _ask_question_from_shell(self, question: str) -> None:
|
|
267
|
+
"""Answer a question posed from the shell thread (question already collected)."""
|
|
268
|
+
if self.no_qa or self.llm_fn is None:
|
|
269
|
+
from tutor.cli import theme
|
|
270
|
+
|
|
271
|
+
print(theme.yellow(" Q&A is disabled (no API key or --no-qa set)."))
|
|
272
|
+
return
|
|
273
|
+
if self._current_idx >= len(self.units):
|
|
274
|
+
from tutor.cli import theme
|
|
275
|
+
|
|
276
|
+
print(theme.red(" No unit loaded."))
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
self._state = "ANSWERING"
|
|
280
|
+
from tutor.cli import theme
|
|
281
|
+
|
|
282
|
+
print(theme.dim(" Thinking..."))
|
|
283
|
+
|
|
284
|
+
from tutor.qa import qa
|
|
285
|
+
|
|
286
|
+
current_unit = self.units[self._current_idx]
|
|
287
|
+
assert self.session is not None
|
|
288
|
+
answer_text = qa.answer(
|
|
289
|
+
question=question,
|
|
290
|
+
current_unit=current_unit,
|
|
291
|
+
all_chunks=self.chunks,
|
|
292
|
+
session=self.session,
|
|
293
|
+
llm_fn=self.llm_fn,
|
|
294
|
+
position_seconds=self._elapsed_seconds(),
|
|
295
|
+
)
|
|
296
|
+
self.qa_count += 1
|
|
297
|
+
|
|
298
|
+
print(f"\n {theme.bold(current_unit.concept)}")
|
|
299
|
+
print(f" {answer_text}\n")
|
|
300
|
+
self._state = "PAUSED"
|
|
301
|
+
|
|
302
|
+
def _on_session_complete(self) -> None:
|
|
303
|
+
self._state = "STOPPED"
|
|
304
|
+
player_display.print_session_complete(
|
|
305
|
+
unit_count=len(self.units),
|
|
306
|
+
total_s=sum(self._get_duration(f) for f in self.unit_files),
|
|
307
|
+
qa_count=self.qa_count,
|
|
308
|
+
)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from tutor.constants import PLAYER_BAR_WIDTH
|
|
5
|
+
from tutor.models import TeachingUnit
|
|
6
|
+
|
|
7
|
+
log = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
BORDER = "━" * 56
|
|
10
|
+
COMMANDS_PLAYING = " [space] pause [?] ask [n] next [b] prev [q] quit"
|
|
11
|
+
COMMANDS_PAUSED = (
|
|
12
|
+
" [space] resume [?] ask [n] next [b] prev [r] replay [s] summary [q] quit"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_first_render = True
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def render_status(
|
|
19
|
+
unit: TeachingUnit,
|
|
20
|
+
unit_idx: int,
|
|
21
|
+
total_units: int,
|
|
22
|
+
elapsed_s: int,
|
|
23
|
+
total_s: int,
|
|
24
|
+
state: str,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Redraw the status bar in-place using ANSI escape codes. Does not scroll."""
|
|
27
|
+
global _first_render
|
|
28
|
+
if not _first_render:
|
|
29
|
+
clear_status()
|
|
30
|
+
_first_render = False
|
|
31
|
+
|
|
32
|
+
bar = _progress_bar(elapsed_s, total_s)
|
|
33
|
+
elapsed_fmt = _fmt_time(elapsed_s)
|
|
34
|
+
total_fmt = _fmt_time(total_s)
|
|
35
|
+
state_tag = " ⏸ PAUSED" if state == "PAUSED" else ""
|
|
36
|
+
|
|
37
|
+
line1 = f" {unit.concept} — Unit {unit_idx}/{total_units}{state_tag}"
|
|
38
|
+
line2 = f" {bar} {elapsed_fmt} / {total_fmt}"
|
|
39
|
+
cmds = COMMANDS_PAUSED if state in ("PAUSED", "ASKING") else COMMANDS_PLAYING
|
|
40
|
+
|
|
41
|
+
sys.stdout.write(f"{BORDER}\n{line1}\n{line2}\n{BORDER}\n{cmds}\n")
|
|
42
|
+
sys.stdout.flush()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def clear_status() -> None:
|
|
46
|
+
"""Move cursor up 5 lines and clear each line."""
|
|
47
|
+
for _ in range(5):
|
|
48
|
+
sys.stdout.write("\033[F\033[2K")
|
|
49
|
+
sys.stdout.flush()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def print_summary(unit: TeachingUnit) -> None:
|
|
53
|
+
print(f"\n{BORDER}")
|
|
54
|
+
print(f" Summary: {unit.concept}")
|
|
55
|
+
print(" Key facts:")
|
|
56
|
+
for fact in unit.key_facts:
|
|
57
|
+
print(f" • {fact}")
|
|
58
|
+
print(f" Remember: {unit.memory_hook}")
|
|
59
|
+
print(f"{BORDER}\n")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def print_session_complete(unit_count: int, total_s: int, qa_count: int) -> None:
|
|
63
|
+
print(f"\n{BORDER}")
|
|
64
|
+
print(f" Session complete: {unit_count} units, {_fmt_time(total_s)}")
|
|
65
|
+
if qa_count:
|
|
66
|
+
print(f" You asked {qa_count} question(s) this session.")
|
|
67
|
+
print(f"{BORDER}")
|
|
68
|
+
print(" [r] replay session [q] quit\n")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def print_qa_disabled() -> None:
|
|
72
|
+
print("\nQ&A disabled (--no-qa). Press [space] to resume.")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def print_thinking() -> None:
|
|
76
|
+
print("\nThinking...", end="", flush=True)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def print_no_context() -> None:
|
|
80
|
+
print("\n(No unit context available)")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def print_resume_hint() -> None:
|
|
84
|
+
print("Press [space] to resume or [?] to ask another question.\n")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def print_question_header(topic: str, position_fmt: str) -> None:
|
|
88
|
+
print("\n── Ask a question ──────────────────────────────────")
|
|
89
|
+
print(f"Topic: {topic} | Position: {position_fmt}")
|
|
90
|
+
print()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def print_cancelled() -> None:
|
|
94
|
+
print(" (cancelled)")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def print_answer(answer: str, unit_concept: str) -> None:
|
|
98
|
+
border = "─" * 56
|
|
99
|
+
print(f"\n{border}")
|
|
100
|
+
print(f"Answer:\n{answer}")
|
|
101
|
+
suffix = border[len(unit_concept) + 10 :]
|
|
102
|
+
print(f"\n── Source: §{unit_concept} {suffix}")
|
|
103
|
+
print()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _progress_bar(elapsed_s: int, total_s: int) -> str:
|
|
107
|
+
if total_s <= 0:
|
|
108
|
+
return "[" + "░" * PLAYER_BAR_WIDTH + "]"
|
|
109
|
+
ratio = min(elapsed_s / total_s, 1.0)
|
|
110
|
+
filled = int(ratio * PLAYER_BAR_WIDTH)
|
|
111
|
+
empty = PLAYER_BAR_WIDTH - filled
|
|
112
|
+
return "[" + "█" * filled + "░" * empty + "]"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _fmt_time(seconds: int) -> str:
|
|
116
|
+
m, s = divmod(max(seconds, 0), 60)
|
|
117
|
+
return f"{m:02d}:{s:02d}"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
You are an expert educator planning an audio episode in which two Java experts
|
|
2
|
+
explain a document to listeners in a clear, step-by-step conversation.
|
|
3
|
+
|
|
4
|
+
Document title: {doc_title}
|
|
5
|
+
Target duration: {duration_min} minutes
|
|
6
|
+
Difficulty: {difficulty}
|
|
7
|
+
Audience: {difficulty_context}
|
|
8
|
+
|
|
9
|
+
AUDIENCE BACKGROUND: The listener already knows JavaScript — functions, objects,
|
|
10
|
+
prototypes, dynamic typing. Calibrate accordingly:
|
|
11
|
+
- If Java behaves the same as JS, note it briefly and move on.
|
|
12
|
+
- If Java behaves differently, that contrast is the most valuable thing to highlight.
|
|
13
|
+
- Never explain what a variable or loop is from scratch.
|
|
14
|
+
|
|
15
|
+
YOUR TASK: Read the section summaries and plan 3–8 teaching units that together
|
|
16
|
+
walk the listener through the document's key concepts from start to finish.
|
|
17
|
+
|
|
18
|
+
Each unit must:
|
|
19
|
+
- Cover one clear, focused concept from the document
|
|
20
|
+
- Have a concrete analogy that works in spoken audio (no code symbols, no diagrams)
|
|
21
|
+
- Flow naturally from the previous unit — sequence matters
|
|
22
|
+
- Build the listener's understanding progressively
|
|
23
|
+
|
|
24
|
+
Sequencing rule: start with the most foundational idea, move towards more nuanced
|
|
25
|
+
or complex ones. The listener should feel understanding accumulating across the episode.
|
|
26
|
+
|
|
27
|
+
Java concept reference — use this to identify what is most important in the source:
|
|
28
|
+
|
|
29
|
+
Tier 1 — Type system
|
|
30
|
+
primitives vs reference types | pass-by-value | autoboxing traps
|
|
31
|
+
|
|
32
|
+
Tier 2 — OOP mechanics
|
|
33
|
+
constructors and this() | inheritance: extends / super() / @Override
|
|
34
|
+
is-a vs has-a | what is NOT inherited | overloading vs overriding | final
|
|
35
|
+
|
|
36
|
+
Tier 3 — Contracts and polymorphism
|
|
37
|
+
interface as contract | interface vs abstract class | dynamic dispatch
|
|
38
|
+
compile-time vs runtime polymorphism | instanceof | Comparable / Iterable
|
|
39
|
+
|
|
40
|
+
Tier 4 — Core contracts
|
|
41
|
+
equals() and hashCode() | == vs .equals() | Comparable vs Comparator
|
|
42
|
+
|
|
43
|
+
Tier 5 — Collections
|
|
44
|
+
List / Set / Map | ArrayList vs LinkedList | HashMap internals
|
|
45
|
+
|
|
46
|
+
Tier 6 — Error handling
|
|
47
|
+
exception hierarchy | checked vs unchecked | try-catch / finally
|
|
48
|
+
throw vs throws | custom exceptions | never swallow silently
|
|
49
|
+
|
|
50
|
+
Section summaries:
|
|
51
|
+
{summaries}
|
|
52
|
+
|
|
53
|
+
Respond with a raw JSON array only. No text outside the array. Each element must have:
|
|
54
|
+
- concept (string) — the concept name, as a listener would hear it
|
|
55
|
+
- source_sections (array of chunk IDs from the summaries above)
|
|
56
|
+
- complexity (integer 1, 2, or 3)
|
|
57
|
+
- key_facts (array of 2–4 strings — the most important things to say about this concept)
|
|
58
|
+
- common_misconception (string — the key insight that makes this concept click once you
|
|
59
|
+
truly understand it; the thing most explanations leave vague)
|
|
60
|
+
- good_analogy (string — a concrete everyday analogy that works purely in spoken words;
|
|
61
|
+
no code, no symbols — something a non-programmer could picture)
|
|
62
|
+
- js_contrast (string — "In JavaScript X; in Java Y" — empty string if not applicable)
|
|
63
|
+
- question_style (string — one of: recall, error-spotting, judgment, predict-output, teach-back)
|
|
64
|
+
- memory_hook (string — one short, memorable sentence the listener will carry away)
|
|
65
|
+
- prerequisite_concepts (array of strings — concepts from earlier units this one builds on)
|
|
66
|
+
- production_relevance (string — one sentence grounding this in real backend work:
|
|
67
|
+
Spring Boot, REST APIs, databases, or microservices)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
You are writing a script for two Java experts hosting an educational audio episode.
|
|
2
|
+
|
|
3
|
+
ALEX is male. MAYA is female. Both are experienced Java engineers and clear communicators.
|
|
4
|
+
They are NOT teacher and student — they are co-hosts explaining a concept to the listener
|
|
5
|
+
together. Their tone is warm, natural, and direct. They complement each other: one sets
|
|
6
|
+
up an idea, the other deepens or grounds it.
|
|
7
|
+
|
|
8
|
+
WORD BUDGET: approximately {word_budget} words total (±15%).
|
|
9
|
+
|
|
10
|
+
CODE IN SPEECH — never write symbols, only spoken English:
|
|
11
|
+
List<String> → "a List of Strings"
|
|
12
|
+
!= → "not equal to"
|
|
13
|
+
== → "double equals"
|
|
14
|
+
.equals() → "dot equals"
|
|
15
|
+
@Override → "the Override annotation"
|
|
16
|
+
int[] → "int array"
|
|
17
|
+
super() → "super constructor call"
|
|
18
|
+
try-catch → "try-catch block"
|
|
19
|
+
instanceof → "instance of"
|
|
20
|
+
extends → "extends"
|
|
21
|
+
implements → "implements"
|
|
22
|
+
throws → "throws declaration"
|
|
23
|
+
|
|
24
|
+
RULES:
|
|
25
|
+
- Use the provided analogy exactly as given. Do not invent a different one.
|
|
26
|
+
- If js_contrast is non-empty, work it in naturally — one of them says something like
|
|
27
|
+
"if you're coming from JavaScript, you might expect..." — make the contrast explicit.
|
|
28
|
+
- End with the memory_hook spoken naturally as a closing thought from one of them.
|
|
29
|
+
- If prerequisite_concepts is non-empty, open with a brief, one-sentence connection
|
|
30
|
+
to that prior concept before introducing this one.
|
|
31
|
+
- No filler praise: no "Excellent!", "Great!", "Amazing!", "Absolutely!", "Perfect!".
|
|
32
|
+
Just natural acknowledgement and continuation.
|
|
33
|
+
- Output only labeled lines: ALEX: ... / MAYA: ...
|
|
34
|
+
- No blank lines between turns. No stage directions. No other speakers.
|
|
35
|
+
|
|
36
|
+
BEAT STRUCTURE — follow this order:
|
|
37
|
+
|
|
38
|
+
1. ALEX — Name the concept and open with WHY it matters to understand it well.
|
|
39
|
+
One or two sentences. If prerequisite_concepts is set, link to it first.
|
|
40
|
+
|
|
41
|
+
2. MAYA — Add what makes this concept interesting or where people tend to get
|
|
42
|
+
confused. Draw from common_misconception — not as a wrong answer, but as the
|
|
43
|
+
thing that becomes clear once you really get it.
|
|
44
|
+
|
|
45
|
+
3. ALEX — Introduce the analogy: "think of it like..." Build the mental picture
|
|
46
|
+
in two or three sentences using the provided good_analogy.
|
|
47
|
+
|
|
48
|
+
4. MAYA — Extend the analogy or connect it to the first key fact. Make it
|
|
49
|
+
concrete for the listener — add a second angle that locks the image in.
|
|
50
|
+
|
|
51
|
+
5. ALEX — State the precise rule or mechanism the listener must remember.
|
|
52
|
+
Pull from key_facts. Keep it crisp and definitive.
|
|
53
|
+
|
|
54
|
+
6. MAYA — Translate that rule into a real scenario: "so in practice, this means..."
|
|
55
|
+
or "the moment this shows up in code is when...". Ground it so the listener
|
|
56
|
+
can picture it happening.
|
|
57
|
+
|
|
58
|
+
7. ALEX — Add the second or third key fact, or the js_contrast if not yet used.
|
|
59
|
+
Connect to production_relevance — one sentence on where this matters in real work.
|
|
60
|
+
|
|
61
|
+
8. MAYA — Close with the memory_hook as a natural takeaway. Something the listener
|
|
62
|
+
will remember when they encounter this in code.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
You are ALEX, a clear and calm Java educator reading a tutorial aloud to a student
|
|
2
|
+
who is following along on screen, scrolling through the document at the same pace.
|
|
3
|
+
|
|
4
|
+
Document: {doc_title}
|
|
5
|
+
Section {section_index} of {total_sections}: {heading}
|
|
6
|
+
Target word count: approximately {word_budget} words
|
|
7
|
+
|
|
8
|
+
Your job: read this section aloud, top to bottom, in natural spoken English.
|
|
9
|
+
|
|
10
|
+
Rules:
|
|
11
|
+
- Open with a single orienting sentence that names the section: "Section {section_index} covers..." or "This part is about...".
|
|
12
|
+
- Follow the section content in order. Do not skip, reorder, or add content not in the source.
|
|
13
|
+
- Translate every code example and symbol into spoken English:
|
|
14
|
+
List<String> → "a List of Strings"
|
|
15
|
+
@Override → "the Override annotation"
|
|
16
|
+
super() → "the super constructor call"
|
|
17
|
+
== → "double equals"
|
|
18
|
+
!= → "not equal to"
|
|
19
|
+
.equals() → "dot equals"
|
|
20
|
+
int[] → "int array"
|
|
21
|
+
try-catch → "try-catch block"
|
|
22
|
+
instanceof → "instance of"
|
|
23
|
+
throws → "throws declaration"
|
|
24
|
+
- Reference what the reader can see on screen: "the table here shows...", "in this code block...", "as the example above shows...".
|
|
25
|
+
- Speak in second person: "you will notice...", "here you can see...", "as you read...".
|
|
26
|
+
- Keep sentences short. One idea per sentence.
|
|
27
|
+
- Do not quiz, test, or ask the student questions. Just narrate.
|
|
28
|
+
- Output one ALEX: line per paragraph, table, code block, or logical group — not one giant line.
|
|
29
|
+
Aim for 4–10 ALEX: lines total.
|
|
30
|
+
- Output only labeled lines in the format: ALEX: <spoken text>
|
|
31
|
+
No blank lines. No stage directions. No other speakers.
|
|
32
|
+
|
|
33
|
+
Source section:
|
|
34
|
+
{section_text}
|
tutor/prompts/qa.txt
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
You are answering a student's question during a Java audio tutorial.
|
|
2
|
+
The student just heard a dialogue about: {concept}.
|
|
3
|
+
The student already knows JavaScript — calibrate your answer to their background:
|
|
4
|
+
if the Java behavior is the same as JavaScript, say so briefly; if it differs,
|
|
5
|
+
make that contrast the centre of your answer.
|
|
6
|
+
|
|
7
|
+
Answer in 4–6 sentences. Rules:
|
|
8
|
+
- Ground your answer in the provided source content. Quote it if helpful.
|
|
9
|
+
- Do not re-explain what the audio already covered — add to it, correct a
|
|
10
|
+
misunderstanding, or go one level deeper.
|
|
11
|
+
- If the concept connects to a real backend use case (Spring Boot, REST APIs,
|
|
12
|
+
database layers, microservices), mention it in one sentence.
|
|
13
|
+
- If the question is outside the source material, say so in one sentence and give
|
|
14
|
+
a brief general answer, clearly labeled as outside the tutorial scope.
|
|
15
|
+
- End with one short follow-up question that pushes their thinking one step further.
|
|
16
|
+
- Cite the source section your answer draws from: (§ section name).
|
|
17
|
+
- No "Great question!". No filler. Answer and move on.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Summarize the following section of a Java tutorial in exactly 4 sentences.
|
|
2
|
+
|
|
3
|
+
Sentence 1 — Concept: state the core concept introduced and what the learner must understand about it.
|
|
4
|
+
Sentence 2 — Key rule or trap: state the most important rule, OR the most common mistake a learner makes here (use the ⚠️ warnings or "common misconception" callouts in the text as your guide).
|
|
5
|
+
Sentence 3 — JavaScript contrast: if the section draws a comparison to JavaScript, state what is the same and what is genuinely different in Java. If no JS contrast exists, state one concrete code-level detail (a method name, a keyword rule, an example outcome).
|
|
6
|
+
Sentence 4 — Sequence: state what prior concept this builds on, and what later concept depends on understanding this correctly.
|
|
7
|
+
|
|
8
|
+
Do not write "this section explains" — write as facts, not as a document description.
|
|
9
|
+
Do not copy sentences verbatim from the source — restate in your own words.
|
tutor/prompts/visual.txt
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
You are generating slide content for a Java audio tutorial visual.
|
|
2
|
+
|
|
3
|
+
Given the unit metadata below, produce a JSON object with these exact fields:
|
|
4
|
+
hook_question — one sharp question ALEX opens with (max 15 words)
|
|
5
|
+
key_points — list of 3 to 5 bullet strings (max 12 words each, plain English)
|
|
6
|
+
code_snippet — short Java code string if relevant, else null
|
|
7
|
+
diagram_type — one of: class_diagram | flowchart | code_comparison | concept_map | none
|
|
8
|
+
diagram_spec — see below
|
|
9
|
+
memory_hook — the memory_hook field restated as a punchy sentence (max 10 words)
|
|
10
|
+
|
|
11
|
+
DIAGRAM_SPEC VALUE RULES:
|
|
12
|
+
|
|
13
|
+
For class_diagram, flowchart, or concept_map:
|
|
14
|
+
diagram_spec must be a JSON STRING containing valid Graphviz DOT code.
|
|
15
|
+
Do NOT make it a JSON object. It is a plain string.
|
|
16
|
+
|
|
17
|
+
For code_comparison:
|
|
18
|
+
diagram_spec must be a JSON object with keys: wrong, right, label_wrong, label_right
|
|
19
|
+
|
|
20
|
+
For none:
|
|
21
|
+
diagram_spec must be null
|
|
22
|
+
|
|
23
|
+
COMPLETE OUTPUT EXAMPLE (concept_map):
|
|
24
|
+
|
|
25
|
+
{
|
|
26
|
+
"hook_question": "Interface or abstract class — which fits?",
|
|
27
|
+
"key_points": [
|
|
28
|
+
"Interface defines a contract",
|
|
29
|
+
"Abstract class adds shared state",
|
|
30
|
+
"Use interface for multiple inheritance"
|
|
31
|
+
],
|
|
32
|
+
"code_snippet": null,
|
|
33
|
+
"diagram_type": "concept_map",
|
|
34
|
+
"diagram_spec": "graph G {\n layout=neato\n graph [bgcolor=\"#0d1117\"]\n node [style=\"filled,rounded\" fillcolor=\"#161b22\" color=\"#30363d\" fontcolor=\"#e6edf3\" fontsize=13 shape=box]\n edge [color=\"#8b949e\" fontcolor=\"#8b949e\" fontsize=10]\n Interface [label=\"Interface\" fontsize=16 color=\"#00b4d8\"]\n Abstract [label=\"Abstract Class\"]\n Interface -- Abstract [label=\"vs\"]\n}",
|
|
35
|
+
"memory_hook": "Interface = contract, abstract class = blueprint."
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
COMPLETE OUTPUT EXAMPLE (flowchart):
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
"hook_question": "Does == compare value or reference?",
|
|
42
|
+
"key_points": [
|
|
43
|
+
"== compares object references",
|
|
44
|
+
".equals() compares content",
|
|
45
|
+
"Always use .equals() for strings"
|
|
46
|
+
],
|
|
47
|
+
"code_snippet": "s1.equals(s2)",
|
|
48
|
+
"diagram_type": "flowchart",
|
|
49
|
+
"diagram_spec": "digraph G {\n rankdir=TB\n graph [bgcolor=\"#0d1117\"]\n node [style=filled fillcolor=\"#161b22\" color=\"#30363d\" fontcolor=\"#e6edf3\" fontsize=13]\n edge [color=\"#8b949e\" fontcolor=\"#8b949e\" fontsize=11]\n start [label=\"Compare strings\" shape=oval color=\"#00b4d8\" fillcolor=\"#1a1a2e\"]\n decide [label=\"Same object?\" shape=diamond color=\"#e3b341\" fillcolor=\"#2a1a0d\"]\n yes [label=\"== is true\" shape=box color=\"#3fb950\" fillcolor=\"#0d2a1a\"]\n no [label=\"Use .equals()\" shape=box]\n start -> decide\n decide -> yes [label=\"yes\"]\n decide -> no [label=\"no\"]\n}",
|
|
50
|
+
"memory_hook": "Strings need .equals(), not ==."
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
CRITICAL RULES for the DOT string:
|
|
54
|
+
- It is a JSON string value — write it as a single string with \n for newlines
|
|
55
|
+
- It MUST start with digraph or graph and contain { ... }
|
|
56
|
+
- Statements separated by newlines (\n), never semicolons
|
|
57
|
+
- Maximum 6 nodes
|
|
58
|
+
- Node labels: plain text only, no unescaped quotes inside the string
|
|
59
|
+
|
|
60
|
+
Output only the JSON object. No prose, no markdown fences before or after.
|