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
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Tests for plan_segments() public API (output shape, JSON output, caching)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from tutor.generation.segment_planner import plan_segments
|
|
8
|
+
|
|
9
|
+
from .conftest import (
|
|
10
|
+
N_LINES,
|
|
11
|
+
_fake_llm,
|
|
12
|
+
_make_lines,
|
|
13
|
+
_make_unit_entry,
|
|
14
|
+
_units_json,
|
|
15
|
+
_valid_response,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Output shape
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_plan_segments_returns_dict_keyed_by_unit(tmp_path):
|
|
24
|
+
units = [_make_unit_entry(1), _make_unit_entry(2)]
|
|
25
|
+
units_json = _units_json(tmp_path, units)
|
|
26
|
+
lines = _make_lines()
|
|
27
|
+
result = plan_segments(units_json, tmp_path / "video", _fake_llm(lines))
|
|
28
|
+
assert isinstance(result, dict)
|
|
29
|
+
assert 1 in result
|
|
30
|
+
assert 2 in result
|
|
31
|
+
assert all(isinstance(k, int) for k in result)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_all_lines_covered(tmp_path):
|
|
35
|
+
n = N_LINES
|
|
36
|
+
units = [_make_unit_entry(1, n_lines=n)]
|
|
37
|
+
units_json = _units_json(tmp_path, units)
|
|
38
|
+
lines = _make_lines(n)
|
|
39
|
+
result = plan_segments(units_json, tmp_path / "video", _fake_llm(lines))
|
|
40
|
+
segs = result[1]
|
|
41
|
+
covered = set()
|
|
42
|
+
for seg in segs:
|
|
43
|
+
covered.update(range(seg.lines_start, seg.lines_end + 1))
|
|
44
|
+
assert covered == set(range(n))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_no_line_covered_twice(tmp_path):
|
|
48
|
+
n = N_LINES
|
|
49
|
+
units = [_make_unit_entry(1, n_lines=n)]
|
|
50
|
+
units_json = _units_json(tmp_path, units)
|
|
51
|
+
lines = _make_lines(n)
|
|
52
|
+
result = plan_segments(units_json, tmp_path / "video", _fake_llm(lines))
|
|
53
|
+
segs = result[1]
|
|
54
|
+
covered = []
|
|
55
|
+
for seg in segs:
|
|
56
|
+
covered.extend(range(seg.lines_start, seg.lines_end + 1))
|
|
57
|
+
assert len(covered) == len(set(covered)), "Some lines covered twice"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_first_segment_is_hook_question(tmp_path):
|
|
61
|
+
units = [_make_unit_entry(1)]
|
|
62
|
+
units_json = _units_json(tmp_path, units)
|
|
63
|
+
lines = _make_lines()
|
|
64
|
+
result = plan_segments(units_json, tmp_path / "video", _fake_llm(lines))
|
|
65
|
+
assert result[1][0].visual_type == "hook_question"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_last_segment_is_memory_hook(tmp_path):
|
|
69
|
+
units = [_make_unit_entry(1)]
|
|
70
|
+
units_json = _units_json(tmp_path, units)
|
|
71
|
+
lines = _make_lines()
|
|
72
|
+
result = plan_segments(units_json, tmp_path / "video", _fake_llm(lines))
|
|
73
|
+
assert result[1][-1].visual_type == "memory_hook"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_segment_index_is_sequential(tmp_path):
|
|
77
|
+
units = [_make_unit_entry(1)]
|
|
78
|
+
units_json = _units_json(tmp_path, units)
|
|
79
|
+
lines = _make_lines()
|
|
80
|
+
result = plan_segments(units_json, tmp_path / "video", _fake_llm(lines))
|
|
81
|
+
indices = [s.segment_index for s in result[1]]
|
|
82
|
+
assert indices == list(range(len(indices)))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_unit_with_zero_lines_skipped_not_crashed(tmp_path):
|
|
86
|
+
units = [_make_unit_entry(1, n_lines=0), _make_unit_entry(2, n_lines=N_LINES)]
|
|
87
|
+
units_json = _units_json(tmp_path, units)
|
|
88
|
+
lines = _make_lines()
|
|
89
|
+
result = plan_segments(units_json, tmp_path / "video", _fake_llm(lines))
|
|
90
|
+
assert 1 not in result
|
|
91
|
+
assert 2 in result
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# JSON output file
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_segments_json_written_to_video_dir(tmp_path):
|
|
100
|
+
units = [_make_unit_entry(1)]
|
|
101
|
+
units_json = _units_json(tmp_path, units)
|
|
102
|
+
lines = _make_lines()
|
|
103
|
+
video_dir = tmp_path / "video"
|
|
104
|
+
plan_segments(units_json, video_dir, _fake_llm(lines))
|
|
105
|
+
assert (video_dir / "tutorial.segments.json").exists()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_segments_json_has_version_1(tmp_path):
|
|
109
|
+
units = [_make_unit_entry(1)]
|
|
110
|
+
units_json = _units_json(tmp_path, units)
|
|
111
|
+
lines = _make_lines()
|
|
112
|
+
video_dir = tmp_path / "video"
|
|
113
|
+
plan_segments(units_json, video_dir, _fake_llm(lines))
|
|
114
|
+
data = json.loads((video_dir / "tutorial.segments.json").read_text())
|
|
115
|
+
assert data["version"] == 1
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# Caching
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_cache_hit_skips_llm_call(tmp_path):
|
|
124
|
+
units = [_make_unit_entry(1)]
|
|
125
|
+
units_json = _units_json(tmp_path, units)
|
|
126
|
+
lines = _make_lines()
|
|
127
|
+
call_count = 0
|
|
128
|
+
|
|
129
|
+
def counting_llm(messages, call_type="segments"):
|
|
130
|
+
nonlocal call_count
|
|
131
|
+
call_count += 1
|
|
132
|
+
return _valid_response(lines)
|
|
133
|
+
|
|
134
|
+
video_dir = tmp_path / "video"
|
|
135
|
+
plan_segments(units_json, video_dir, counting_llm, no_cache=True)
|
|
136
|
+
assert call_count == 1
|
|
137
|
+
|
|
138
|
+
plan_segments(units_json, video_dir, counting_llm)
|
|
139
|
+
assert call_count == 1
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_no_cache_forces_regeneration(tmp_path):
|
|
143
|
+
units = [_make_unit_entry(1)]
|
|
144
|
+
units_json = _units_json(tmp_path, units)
|
|
145
|
+
lines = _make_lines()
|
|
146
|
+
call_count = 0
|
|
147
|
+
|
|
148
|
+
def counting_llm(messages, call_type="segments"):
|
|
149
|
+
nonlocal call_count
|
|
150
|
+
call_count += 1
|
|
151
|
+
return _valid_response(lines)
|
|
152
|
+
|
|
153
|
+
video_dir = tmp_path / "video"
|
|
154
|
+
plan_segments(units_json, video_dir, counting_llm, no_cache=True)
|
|
155
|
+
assert call_count == 1
|
|
156
|
+
|
|
157
|
+
plan_segments(units_json, video_dir, counting_llm, no_cache=True)
|
|
158
|
+
assert call_count == 2
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# Backward-compat: visual_planner API still callable
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
# New types: step_sequence and callout
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_step_sequence_in_valid_types():
|
|
172
|
+
from tutor.models import VALID_VISUAL_TYPES
|
|
173
|
+
|
|
174
|
+
assert "step_sequence" in VALID_VISUAL_TYPES
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_callout_in_valid_types():
|
|
178
|
+
from tutor.models import VALID_VISUAL_TYPES
|
|
179
|
+
|
|
180
|
+
assert "callout" in VALID_VISUAL_TYPES
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_step_sequence_fallback_on_empty_body(caplog):
|
|
184
|
+
"""step_sequence with empty body is reclassified to definition."""
|
|
185
|
+
import logging
|
|
186
|
+
|
|
187
|
+
from tutor.generation.segment_parser import _validate_segment
|
|
188
|
+
from tutor.models import SlideSegment
|
|
189
|
+
|
|
190
|
+
seg = SlideSegment(
|
|
191
|
+
unit_index=1,
|
|
192
|
+
segment_index=0,
|
|
193
|
+
lines_start=0,
|
|
194
|
+
lines_end=1,
|
|
195
|
+
visual_type="step_sequence",
|
|
196
|
+
title="Steps to deploy",
|
|
197
|
+
body=None,
|
|
198
|
+
code=None,
|
|
199
|
+
language=None,
|
|
200
|
+
mermaid=None,
|
|
201
|
+
left=None,
|
|
202
|
+
right=None,
|
|
203
|
+
rows=None,
|
|
204
|
+
)
|
|
205
|
+
with caplog.at_level(logging.WARNING):
|
|
206
|
+
result = _validate_segment(seg)
|
|
207
|
+
assert result.visual_type == "definition"
|
|
208
|
+
assert "step_sequence" in caplog.text
|
|
209
|
+
assert "body is empty" in caplog.text
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_callout_fallback_on_empty_body(caplog):
|
|
213
|
+
"""callout with empty body is reclassified to key_insight."""
|
|
214
|
+
import logging
|
|
215
|
+
|
|
216
|
+
from tutor.generation.segment_parser import _validate_segment
|
|
217
|
+
from tutor.models import SlideSegment
|
|
218
|
+
|
|
219
|
+
seg = SlideSegment(
|
|
220
|
+
unit_index=1,
|
|
221
|
+
segment_index=0,
|
|
222
|
+
lines_start=0,
|
|
223
|
+
lines_end=1,
|
|
224
|
+
visual_type="callout",
|
|
225
|
+
title="WARNING",
|
|
226
|
+
body=None,
|
|
227
|
+
code=None,
|
|
228
|
+
language=None,
|
|
229
|
+
mermaid=None,
|
|
230
|
+
left=None,
|
|
231
|
+
right=None,
|
|
232
|
+
rows=None,
|
|
233
|
+
)
|
|
234
|
+
with caplog.at_level(logging.WARNING):
|
|
235
|
+
result = _validate_segment(seg)
|
|
236
|
+
assert result.visual_type == "key_insight"
|
|
237
|
+
assert "callout" in caplog.text
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_valid_step_sequence_passes_validation():
|
|
241
|
+
"""step_sequence with body is accepted without reclassification."""
|
|
242
|
+
from tutor.generation.segment_parser import _validate_segment
|
|
243
|
+
from tutor.models import SlideSegment
|
|
244
|
+
|
|
245
|
+
seg = SlideSegment(
|
|
246
|
+
unit_index=1,
|
|
247
|
+
segment_index=0,
|
|
248
|
+
lines_start=0,
|
|
249
|
+
lines_end=2,
|
|
250
|
+
visual_type="step_sequence",
|
|
251
|
+
title="How to deploy",
|
|
252
|
+
body="Open the terminal\nRun the build script\nPush to staging",
|
|
253
|
+
code=None,
|
|
254
|
+
language=None,
|
|
255
|
+
mermaid=None,
|
|
256
|
+
left=None,
|
|
257
|
+
right=None,
|
|
258
|
+
rows=None,
|
|
259
|
+
)
|
|
260
|
+
result = _validate_segment(seg)
|
|
261
|
+
assert result.visual_type == "step_sequence"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_valid_callout_passes_validation():
|
|
265
|
+
"""callout with title and body is accepted without reclassification."""
|
|
266
|
+
from tutor.generation.segment_parser import _validate_segment
|
|
267
|
+
from tutor.models import SlideSegment
|
|
268
|
+
|
|
269
|
+
seg = SlideSegment(
|
|
270
|
+
unit_index=1,
|
|
271
|
+
segment_index=0,
|
|
272
|
+
lines_start=4,
|
|
273
|
+
lines_end=5,
|
|
274
|
+
visual_type="callout",
|
|
275
|
+
title="TIP",
|
|
276
|
+
body="Always run the linter before committing — it catches 80% of review feedback.",
|
|
277
|
+
code=None,
|
|
278
|
+
language=None,
|
|
279
|
+
mermaid=None,
|
|
280
|
+
left=None,
|
|
281
|
+
right=None,
|
|
282
|
+
rows=None,
|
|
283
|
+
)
|
|
284
|
+
result = _validate_segment(seg)
|
|
285
|
+
assert result.visual_type == "callout"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def test_visual_planner_plan_visuals_still_callable(tmp_path):
|
|
289
|
+
from dataclasses import asdict as _asdict
|
|
290
|
+
|
|
291
|
+
from tutor.generation.visual_planner import plan_visuals
|
|
292
|
+
from tutor.models import TeachingUnit
|
|
293
|
+
|
|
294
|
+
unit = TeachingUnit(
|
|
295
|
+
unit=1,
|
|
296
|
+
concept="Test Concept",
|
|
297
|
+
source_sections=["s01"],
|
|
298
|
+
complexity=1,
|
|
299
|
+
word_budget=200,
|
|
300
|
+
key_facts=["fact1"],
|
|
301
|
+
common_misconception="none",
|
|
302
|
+
good_analogy="like a box",
|
|
303
|
+
question_style="recall",
|
|
304
|
+
memory_hook="remember the box",
|
|
305
|
+
)
|
|
306
|
+
units_json = tmp_path / "tutorial.units.json"
|
|
307
|
+
units_json.write_text(json.dumps([_asdict(unit)]), encoding="utf-8")
|
|
308
|
+
|
|
309
|
+
def stub_llm(messages, call_type="visual"):
|
|
310
|
+
return json.dumps(
|
|
311
|
+
{
|
|
312
|
+
"hook_question": "What is it?",
|
|
313
|
+
"key_points": ["Point one"],
|
|
314
|
+
"code_snippet": None,
|
|
315
|
+
"diagram_type": "none",
|
|
316
|
+
"diagram_spec": None,
|
|
317
|
+
"memory_hook": "remember",
|
|
318
|
+
"analogy": "",
|
|
319
|
+
}
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
video_dir = tmp_path / "video"
|
|
323
|
+
specs = plan_visuals(units_json, "Test", "test_session", stub_llm, "beginner", video_dir)
|
|
324
|
+
assert len(specs) >= 1
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from dataclasses import asdict
|
|
3
|
+
|
|
4
|
+
from tutor.generation.visual_planner import (
|
|
5
|
+
_build_outro,
|
|
6
|
+
_build_title_card,
|
|
7
|
+
_cache_path,
|
|
8
|
+
_fallback_spec,
|
|
9
|
+
_parse_visual_response,
|
|
10
|
+
_plan_unit,
|
|
11
|
+
plan_visuals,
|
|
12
|
+
)
|
|
13
|
+
from tutor.models import TeachingUnit, VisualSpec
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Fixtures
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _make_unit(idx: int = 1, concept: str = "Pass-by-Value") -> TeachingUnit:
|
|
21
|
+
return TeachingUnit(
|
|
22
|
+
unit=idx,
|
|
23
|
+
concept=concept,
|
|
24
|
+
source_sections=["s01"],
|
|
25
|
+
complexity=2,
|
|
26
|
+
word_budget=400,
|
|
27
|
+
key_facts=["Java passes copies", "Primitives copy values", "References copy the pointer"],
|
|
28
|
+
common_misconception="Java passes objects by reference",
|
|
29
|
+
good_analogy="Copying an address, not a house",
|
|
30
|
+
question_style="recall",
|
|
31
|
+
memory_hook="Copy the address, not the house",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _make_units(n: int = 2) -> list[TeachingUnit]:
|
|
36
|
+
concepts = ["Pass-by-Value", "String Equality", "Interfaces", "Abstract Classes"]
|
|
37
|
+
return [_make_unit(i + 1, concepts[i % len(concepts)]) for i in range(n)]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
GOOD_LLM_RESPONSE = json.dumps(
|
|
41
|
+
{
|
|
42
|
+
"hook_question": "What really happens when Java passes an object?",
|
|
43
|
+
"key_points": [
|
|
44
|
+
"Java always passes copies",
|
|
45
|
+
"Primitive copies hold the value directly",
|
|
46
|
+
"Reference copies hold the memory address",
|
|
47
|
+
],
|
|
48
|
+
"code_snippet": "void mutate(int x) { x = 99; }",
|
|
49
|
+
"diagram_type": "flowchart",
|
|
50
|
+
"diagram_spec": "digraph G { rankdir=TB\n A -> B }",
|
|
51
|
+
"memory_hook": "Copy the address, not the house",
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
CODE_COMPARISON_RESPONSE = json.dumps(
|
|
56
|
+
{
|
|
57
|
+
"hook_question": "Which equality check should you use?",
|
|
58
|
+
"key_points": ["Use .equals() for content", "== checks identity"],
|
|
59
|
+
"code_snippet": None,
|
|
60
|
+
"diagram_type": "code_comparison",
|
|
61
|
+
"diagram_spec": {
|
|
62
|
+
"wrong": "if (a == b) {}",
|
|
63
|
+
"right": "if (a.equals(b)) {}",
|
|
64
|
+
"label_wrong": "compares references",
|
|
65
|
+
"label_right": "compares content",
|
|
66
|
+
},
|
|
67
|
+
"memory_hook": "equals for content, == for identity",
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def fake_llm(messages, call_type="dialogue"):
|
|
73
|
+
return GOOD_LLM_RESPONSE
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# _parse_visual_response
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_parse_good_response_returns_visual_spec():
|
|
82
|
+
unit = _make_unit()
|
|
83
|
+
spec = _parse_visual_response(GOOD_LLM_RESPONSE, unit)
|
|
84
|
+
assert isinstance(spec, VisualSpec)
|
|
85
|
+
assert spec.slide_type == "unit"
|
|
86
|
+
assert spec.concept == unit.concept
|
|
87
|
+
assert spec.diagram_type == "flowchart"
|
|
88
|
+
assert spec.hook_question != ""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_parse_code_comparison_response():
|
|
92
|
+
unit = _make_unit()
|
|
93
|
+
spec = _parse_visual_response(CODE_COMPARISON_RESPONSE, unit)
|
|
94
|
+
assert spec.diagram_type == "code_comparison"
|
|
95
|
+
assert isinstance(spec.diagram_spec, dict)
|
|
96
|
+
assert "wrong" in spec.diagram_spec
|
|
97
|
+
assert "right" in spec.diagram_spec
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_invalid_json_returns_fallback():
|
|
101
|
+
unit = _make_unit()
|
|
102
|
+
spec = _parse_visual_response("not json at all %%%", unit)
|
|
103
|
+
assert spec.diagram_type == "none"
|
|
104
|
+
assert spec.diagram_spec is None
|
|
105
|
+
assert spec.concept == unit.concept
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_invalid_diagram_type_becomes_none():
|
|
109
|
+
unit = _make_unit()
|
|
110
|
+
bad = json.dumps(
|
|
111
|
+
{
|
|
112
|
+
"hook_question": "Q?",
|
|
113
|
+
"key_points": ["fact"],
|
|
114
|
+
"code_snippet": None,
|
|
115
|
+
"diagram_type": "pie_chart",
|
|
116
|
+
"diagram_spec": None,
|
|
117
|
+
"memory_hook": "remember this",
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
spec = _parse_visual_response(bad, unit)
|
|
121
|
+
assert spec.diagram_type == "none"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_dot_diagram_without_valid_dot_string_becomes_none():
|
|
125
|
+
unit = _make_unit()
|
|
126
|
+
bad = json.dumps(
|
|
127
|
+
{
|
|
128
|
+
"hook_question": "Q?",
|
|
129
|
+
"key_points": ["fact"],
|
|
130
|
+
"code_snippet": None,
|
|
131
|
+
"diagram_type": "class_diagram",
|
|
132
|
+
"diagram_spec": "this is not dot syntax",
|
|
133
|
+
"memory_hook": "remember",
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
spec = _parse_visual_response(bad, unit)
|
|
137
|
+
assert spec.diagram_type == "none"
|
|
138
|
+
assert spec.diagram_spec is None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_code_comparison_missing_keys_becomes_none():
|
|
142
|
+
unit = _make_unit()
|
|
143
|
+
bad = json.dumps(
|
|
144
|
+
{
|
|
145
|
+
"hook_question": "Q?",
|
|
146
|
+
"key_points": ["fact"],
|
|
147
|
+
"code_snippet": None,
|
|
148
|
+
"diagram_type": "code_comparison",
|
|
149
|
+
"diagram_spec": {"only_wrong": "x = 1"},
|
|
150
|
+
"memory_hook": "remember",
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
spec = _parse_visual_response(bad, unit)
|
|
154
|
+
assert spec.diagram_type == "none"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
# _fallback_spec
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_fallback_spec_fields():
|
|
163
|
+
unit = _make_unit()
|
|
164
|
+
spec = _fallback_spec(unit)
|
|
165
|
+
assert spec.slide_type == "unit"
|
|
166
|
+
assert spec.diagram_type == "none"
|
|
167
|
+
assert spec.diagram_spec is None
|
|
168
|
+
assert spec.unit_index == unit.unit
|
|
169
|
+
assert len(spec.key_points) <= 5
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# _build_title_card / _build_outro
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_title_card_slide_type():
|
|
178
|
+
units = _make_units(3)
|
|
179
|
+
card = _build_title_card("Java Basics", units, "week1_1")
|
|
180
|
+
assert card.slide_type == "title_card"
|
|
181
|
+
assert card.unit_index == 0
|
|
182
|
+
assert "3 units" in card.subtitle
|
|
183
|
+
assert card.title == "Java Basics"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_outro_collects_memory_hooks():
|
|
187
|
+
units = _make_units(2)
|
|
188
|
+
outro = _build_outro(units)
|
|
189
|
+
assert outro.slide_type == "outro"
|
|
190
|
+
assert outro.unit_index == len(units) + 1
|
|
191
|
+
assert len(outro.memory_hooks) == len(units)
|
|
192
|
+
assert units[0].memory_hook in outro.memory_hooks
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
# _plan_unit — caching
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_cache_hit_skips_llm(tmp_path):
|
|
201
|
+
unit = _make_unit()
|
|
202
|
+
cache_file = tmp_path / "test.visual.json"
|
|
203
|
+
spec = _fallback_spec(unit)
|
|
204
|
+
cache_file.write_text(json.dumps(asdict(spec)), encoding="utf-8")
|
|
205
|
+
|
|
206
|
+
call_count = {"n": 0}
|
|
207
|
+
|
|
208
|
+
def counting_llm(messages, call_type="dialogue"):
|
|
209
|
+
call_count["n"] += 1
|
|
210
|
+
return GOOD_LLM_RESPONSE
|
|
211
|
+
|
|
212
|
+
result = _plan_unit(unit, counting_llm, "beginner", cache_file)
|
|
213
|
+
assert call_count["n"] == 0
|
|
214
|
+
assert result.concept == unit.concept
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_cache_miss_calls_llm(tmp_path):
|
|
218
|
+
unit = _make_unit()
|
|
219
|
+
cache_file = tmp_path / "missing.visual.json"
|
|
220
|
+
|
|
221
|
+
call_count = {"n": 0}
|
|
222
|
+
|
|
223
|
+
def counting_llm(messages, call_type="dialogue"):
|
|
224
|
+
call_count["n"] += 1
|
|
225
|
+
return GOOD_LLM_RESPONSE
|
|
226
|
+
|
|
227
|
+
_plan_unit(unit, counting_llm, "beginner", cache_file)
|
|
228
|
+
assert call_count["n"] == 1
|
|
229
|
+
assert cache_file.exists()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_llm_failure_produces_fallback_not_crash(tmp_path):
|
|
233
|
+
unit = _make_unit()
|
|
234
|
+
cache_file = tmp_path / "x.visual.json"
|
|
235
|
+
|
|
236
|
+
def bad_llm(messages, call_type="dialogue"):
|
|
237
|
+
raise RuntimeError("API exploded")
|
|
238
|
+
|
|
239
|
+
spec = _plan_unit(unit, bad_llm, "beginner", cache_file)
|
|
240
|
+
assert spec.diagram_type == "none"
|
|
241
|
+
assert spec.concept == unit.concept
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
# plan_visuals — integration
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def test_plan_visuals_returns_title_and_outro(tmp_path):
|
|
250
|
+
units = _make_units(2)
|
|
251
|
+
units_json = tmp_path / "tutorial.units.json"
|
|
252
|
+
units_json.write_text(
|
|
253
|
+
json.dumps([asdict(u) for u in units], ensure_ascii=False),
|
|
254
|
+
encoding="utf-8",
|
|
255
|
+
)
|
|
256
|
+
video_dir = tmp_path / "video"
|
|
257
|
+
|
|
258
|
+
specs = plan_visuals(units_json, "Java Basics", "week1_1", fake_llm, "beginner", video_dir)
|
|
259
|
+
|
|
260
|
+
assert specs[0].slide_type == "title_card"
|
|
261
|
+
assert specs[-1].slide_type == "outro"
|
|
262
|
+
assert len(specs) == len(units) + 2
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_plan_visuals_writes_json_to_video_dir(tmp_path):
|
|
266
|
+
units = _make_units(1)
|
|
267
|
+
units_json = tmp_path / "tutorial.units.json"
|
|
268
|
+
units_json.write_text(json.dumps([asdict(u) for u in units]), encoding="utf-8")
|
|
269
|
+
video_dir = tmp_path / "video"
|
|
270
|
+
|
|
271
|
+
plan_visuals(units_json, "Java Basics", "week1_1", fake_llm, "beginner", video_dir)
|
|
272
|
+
|
|
273
|
+
out = video_dir / "tutorial.visuals.json"
|
|
274
|
+
assert out.exists()
|
|
275
|
+
data = json.loads(out.read_text(encoding="utf-8"))
|
|
276
|
+
assert isinstance(data, list)
|
|
277
|
+
assert data[0]["slide_type"] == "title_card"
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_plan_visuals_no_cache_clears_cache(tmp_path):
|
|
281
|
+
unit = _make_unit()
|
|
282
|
+
cache_file = _cache_path(unit, "beginner")
|
|
283
|
+
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
|
284
|
+
cache_file.write_text(json.dumps(asdict(_fallback_spec(unit))), encoding="utf-8")
|
|
285
|
+
|
|
286
|
+
units_json = tmp_path / "tutorial.units.json"
|
|
287
|
+
units_json.write_text(json.dumps([asdict(unit)]), encoding="utf-8")
|
|
288
|
+
video_dir = tmp_path / "video"
|
|
289
|
+
|
|
290
|
+
call_count = {"n": 0}
|
|
291
|
+
|
|
292
|
+
def counting_llm(messages, call_type="dialogue"):
|
|
293
|
+
call_count["n"] += 1
|
|
294
|
+
return GOOD_LLM_RESPONSE
|
|
295
|
+
|
|
296
|
+
plan_visuals(units_json, "T", "s", counting_llm, "beginner", video_dir, no_cache=True)
|
|
297
|
+
assert call_count["n"] == 1
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_unit_specs_have_required_fields(tmp_path):
|
|
301
|
+
units = _make_units(2)
|
|
302
|
+
units_json = tmp_path / "tutorial.units.json"
|
|
303
|
+
units_json.write_text(json.dumps([asdict(u) for u in units]), encoding="utf-8")
|
|
304
|
+
video_dir = tmp_path / "video"
|
|
305
|
+
|
|
306
|
+
specs = plan_visuals(units_json, "T", "s", fake_llm, "beginner", video_dir)
|
|
307
|
+
unit_specs = [s for s in specs if s.slide_type == "unit"]
|
|
308
|
+
|
|
309
|
+
for spec in unit_specs:
|
|
310
|
+
assert spec.concept
|
|
311
|
+
assert isinstance(spec.key_points, list)
|
|
312
|
+
assert spec.diagram_type in {
|
|
313
|
+
"class_diagram",
|
|
314
|
+
"flowchart",
|
|
315
|
+
"code_comparison",
|
|
316
|
+
"concept_map",
|
|
317
|
+
"none",
|
|
318
|
+
}
|
|
319
|
+
assert spec.memory_hook is not None
|
|
File without changes
|