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,165 @@
|
|
|
1
|
+
"""Tests for tutor/generation/narrator.py."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
from tutor.generation.narrator import (
|
|
6
|
+
NARRATE_VERSION,
|
|
7
|
+
_chunk_to_unit,
|
|
8
|
+
_parse_narration,
|
|
9
|
+
narrate_all,
|
|
10
|
+
)
|
|
11
|
+
from tutor.models import Chunk
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _make_chunk(
|
|
15
|
+
chunk_id: str = "sec_001",
|
|
16
|
+
heading: str = "What is Inheritance?",
|
|
17
|
+
text: str = "Inheritance lets one class extend another.",
|
|
18
|
+
) -> Chunk:
|
|
19
|
+
return Chunk(
|
|
20
|
+
chunk_id=chunk_id,
|
|
21
|
+
breadcrumb=heading,
|
|
22
|
+
heading=heading,
|
|
23
|
+
level=2,
|
|
24
|
+
token_count=len(text.split()),
|
|
25
|
+
text=text,
|
|
26
|
+
has_code=False,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ── _parse_narration ──────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_parse_narration_extracts_alex_lines():
|
|
34
|
+
raw = "ALEX: This section covers inheritance.\nALEX: Use extends to create a subclass."
|
|
35
|
+
lines = _parse_narration(raw, unit_number=1)
|
|
36
|
+
assert len(lines) == 2
|
|
37
|
+
assert all(ln.speaker == "ALEX" for ln in lines)
|
|
38
|
+
assert lines[0].text == "This section covers inheritance."
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_parse_narration_skips_non_alex_lines():
|
|
42
|
+
raw = "ALEX: Hello.\nSome random text.\nMAYA: Should be ignored.\nALEX: End."
|
|
43
|
+
lines = _parse_narration(raw, unit_number=1)
|
|
44
|
+
assert len(lines) == 2
|
|
45
|
+
assert lines[1].text == "End."
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_parse_narration_sets_unit_number():
|
|
49
|
+
raw = "ALEX: Content here."
|
|
50
|
+
lines = _parse_narration(raw, unit_number=3)
|
|
51
|
+
assert lines[0].unit_number == 3
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_parse_narration_empty_input_returns_empty():
|
|
55
|
+
assert _parse_narration("", unit_number=1) == []
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_parse_narration_handles_dash_separator():
|
|
59
|
+
raw = "ALEX - This also works."
|
|
60
|
+
lines = _parse_narration(raw, unit_number=1)
|
|
61
|
+
assert len(lines) == 1
|
|
62
|
+
assert lines[0].text == "This also works."
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── _chunk_to_unit ────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_chunk_to_unit_concept_is_heading():
|
|
69
|
+
chunk = _make_chunk(heading="The extends Keyword")
|
|
70
|
+
unit = _chunk_to_unit(chunk, unit_index=2)
|
|
71
|
+
assert unit.concept == "The extends Keyword"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_chunk_to_unit_source_sections_contains_chunk_id():
|
|
75
|
+
chunk = _make_chunk(chunk_id="sec_003")
|
|
76
|
+
unit = _chunk_to_unit(chunk, unit_index=3)
|
|
77
|
+
assert "sec_003" in unit.source_sections
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_chunk_to_unit_complexity_is_one():
|
|
81
|
+
chunk = _make_chunk()
|
|
82
|
+
unit = _chunk_to_unit(chunk, unit_index=1)
|
|
83
|
+
assert unit.complexity == 1
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_chunk_to_unit_word_budget_proportional_to_source():
|
|
87
|
+
long_text = " ".join(["word"] * 200)
|
|
88
|
+
chunk = _make_chunk(text=long_text)
|
|
89
|
+
unit = _chunk_to_unit(chunk, unit_index=1)
|
|
90
|
+
assert unit.word_budget >= 200
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_chunk_to_unit_falls_back_to_section_number_when_no_heading():
|
|
94
|
+
chunk = _make_chunk(heading="")
|
|
95
|
+
unit = _chunk_to_unit(chunk, unit_index=5)
|
|
96
|
+
assert "5" in unit.concept
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ── narrate_all ───────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _fake_llm(response: str) -> MagicMock:
|
|
103
|
+
mock = MagicMock(return_value=response)
|
|
104
|
+
return mock
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_narrate_all_returns_one_unit_per_chunk(tmp_path):
|
|
108
|
+
chunks = [_make_chunk(chunk_id=f"sec_{i:03d}", heading=f"Section {i}") for i in range(3)]
|
|
109
|
+
llm_fn = _fake_llm("ALEX: This section explains the concept.\nALEX: Here is a detail.")
|
|
110
|
+
|
|
111
|
+
with patch("tutor.generation.narrator.SUMMARY_CACHE_DIR", str(tmp_path)):
|
|
112
|
+
units, all_lines = narrate_all(chunks, "Inheritance", llm_fn, cache_dir=str(tmp_path))
|
|
113
|
+
|
|
114
|
+
assert len(units) == 3
|
|
115
|
+
assert len(all_lines) == 3
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_narrate_all_units_only_have_alex_lines(tmp_path):
|
|
119
|
+
chunks = [_make_chunk()]
|
|
120
|
+
llm_fn = _fake_llm("ALEX: Only ALEX speaks.\nALEX: And again.")
|
|
121
|
+
|
|
122
|
+
with patch("tutor.generation.narrator.SUMMARY_CACHE_DIR", str(tmp_path)):
|
|
123
|
+
_, all_lines = narrate_all(chunks, "Doc", llm_fn, cache_dir=str(tmp_path))
|
|
124
|
+
|
|
125
|
+
for lines in all_lines:
|
|
126
|
+
assert all(ln.speaker == "ALEX" for ln in lines)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_narrate_all_uses_cache_on_second_call(tmp_path):
|
|
130
|
+
chunks = [_make_chunk()]
|
|
131
|
+
llm_fn = _fake_llm("ALEX: First call.")
|
|
132
|
+
|
|
133
|
+
with patch("tutor.generation.narrator.SUMMARY_CACHE_DIR", str(tmp_path)):
|
|
134
|
+
narrate_all(chunks, "Doc", llm_fn, cache_dir=str(tmp_path))
|
|
135
|
+
narrate_all(chunks, "Doc", llm_fn, cache_dir=str(tmp_path))
|
|
136
|
+
|
|
137
|
+
assert llm_fn.call_count == 1
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_narrate_all_cache_files_use_narrate_suffix(tmp_path):
|
|
141
|
+
chunks = [_make_chunk()]
|
|
142
|
+
llm_fn = _fake_llm("ALEX: Cached content.")
|
|
143
|
+
|
|
144
|
+
with patch("tutor.generation.narrator.SUMMARY_CACHE_DIR", str(tmp_path)):
|
|
145
|
+
narrate_all(chunks, "Doc", llm_fn, cache_dir=str(tmp_path))
|
|
146
|
+
|
|
147
|
+
cache_files = list(tmp_path.glob("*.narrate.json"))
|
|
148
|
+
assert len(cache_files) == 1
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_narrate_all_does_not_share_cache_with_dialogue(tmp_path):
|
|
152
|
+
"""Narrate cache keys must differ from dialogue cache keys for same chunk."""
|
|
153
|
+
import hashlib
|
|
154
|
+
|
|
155
|
+
chunk = _make_chunk()
|
|
156
|
+
narrate_key = hashlib.md5((chunk.chunk_id + chunk.text + NARRATE_VERSION).encode()).hexdigest()
|
|
157
|
+
|
|
158
|
+
# Simulate a dialogue cache file with the same chunk content
|
|
159
|
+
from tutor.constants import PROMPT_VERSION
|
|
160
|
+
|
|
161
|
+
dialogue_key = hashlib.md5(
|
|
162
|
+
(chunk.heading + str(400) + "tutor-student" + "beginner" + PROMPT_VERSION).encode()
|
|
163
|
+
).hexdigest()
|
|
164
|
+
|
|
165
|
+
assert narrate_key != dialogue_key
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Tests for edge cases: fallback, gap filling, invalid LLM responses, field validation."""
|
|
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
|
+
_make_unit_entry,
|
|
11
|
+
_units_json,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Fallback on bad LLM response
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_invalid_json_from_llm_returns_fallback(tmp_path):
|
|
20
|
+
units = [_make_unit_entry(1)]
|
|
21
|
+
units_json = _units_json(tmp_path, units)
|
|
22
|
+
|
|
23
|
+
def bad_llm(messages, call_type="segments"):
|
|
24
|
+
return "this is not JSON at all !!!"
|
|
25
|
+
|
|
26
|
+
result = plan_segments(units_json, tmp_path / "video", bad_llm)
|
|
27
|
+
segs = result[1]
|
|
28
|
+
assert len(segs) >= 1
|
|
29
|
+
assert segs[0].visual_type == "hook_question"
|
|
30
|
+
assert segs[-1].visual_type == "memory_hook"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_llm_exception_returns_fallback(tmp_path):
|
|
34
|
+
units = [_make_unit_entry(1)]
|
|
35
|
+
units_json = _units_json(tmp_path, units)
|
|
36
|
+
|
|
37
|
+
def exploding_llm(messages, call_type="segments"):
|
|
38
|
+
raise RuntimeError("network failure")
|
|
39
|
+
|
|
40
|
+
result = plan_segments(units_json, tmp_path / "video", exploding_llm)
|
|
41
|
+
segs = result[1]
|
|
42
|
+
assert len(segs) >= 1
|
|
43
|
+
assert segs[0].visual_type == "hook_question"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Field validation
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_unknown_visual_type_replaced_with_key_insight(tmp_path):
|
|
52
|
+
def banana_llm(messages, call_type="segments"):
|
|
53
|
+
return json.dumps(
|
|
54
|
+
[
|
|
55
|
+
{
|
|
56
|
+
"lines_start": 0,
|
|
57
|
+
"lines_end": 0,
|
|
58
|
+
"visual_type": "hook_question",
|
|
59
|
+
"title": "Open",
|
|
60
|
+
"body": None,
|
|
61
|
+
"code": None,
|
|
62
|
+
"language": None,
|
|
63
|
+
"mermaid": None,
|
|
64
|
+
"left": None,
|
|
65
|
+
"right": None,
|
|
66
|
+
"rows": None,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"lines_start": 1,
|
|
70
|
+
"lines_end": 2,
|
|
71
|
+
"visual_type": "banana",
|
|
72
|
+
"title": "Unknown",
|
|
73
|
+
"body": None,
|
|
74
|
+
"code": None,
|
|
75
|
+
"language": None,
|
|
76
|
+
"mermaid": None,
|
|
77
|
+
"left": None,
|
|
78
|
+
"right": None,
|
|
79
|
+
"rows": None,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"lines_start": 3,
|
|
83
|
+
"lines_end": 3,
|
|
84
|
+
"visual_type": "memory_hook",
|
|
85
|
+
"title": "Remember",
|
|
86
|
+
"body": None,
|
|
87
|
+
"code": None,
|
|
88
|
+
"language": None,
|
|
89
|
+
"mermaid": None,
|
|
90
|
+
"left": None,
|
|
91
|
+
"right": None,
|
|
92
|
+
"rows": None,
|
|
93
|
+
},
|
|
94
|
+
]
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
units = [_make_unit_entry(1, n_lines=4)]
|
|
98
|
+
units_json = _units_json(tmp_path, units)
|
|
99
|
+
result = plan_segments(units_json, tmp_path / "video", banana_llm)
|
|
100
|
+
types = [s.visual_type for s in result[1]]
|
|
101
|
+
assert "banana" not in types
|
|
102
|
+
assert "key_insight" in types
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_out_of_bounds_indices_clamped(tmp_path):
|
|
106
|
+
n = 5
|
|
107
|
+
|
|
108
|
+
def oob_llm(messages, call_type="segments"):
|
|
109
|
+
return json.dumps(
|
|
110
|
+
[
|
|
111
|
+
{
|
|
112
|
+
"lines_start": 0,
|
|
113
|
+
"lines_end": 0,
|
|
114
|
+
"visual_type": "hook_question",
|
|
115
|
+
"title": "Open",
|
|
116
|
+
"body": None,
|
|
117
|
+
"code": None,
|
|
118
|
+
"language": None,
|
|
119
|
+
"mermaid": None,
|
|
120
|
+
"left": None,
|
|
121
|
+
"right": None,
|
|
122
|
+
"rows": None,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"lines_start": 1,
|
|
126
|
+
"lines_end": 999,
|
|
127
|
+
"visual_type": "key_insight",
|
|
128
|
+
"title": "Key",
|
|
129
|
+
"body": None,
|
|
130
|
+
"code": None,
|
|
131
|
+
"language": None,
|
|
132
|
+
"mermaid": None,
|
|
133
|
+
"left": None,
|
|
134
|
+
"right": None,
|
|
135
|
+
"rows": None,
|
|
136
|
+
},
|
|
137
|
+
]
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
units = [_make_unit_entry(1, n_lines=n)]
|
|
141
|
+
units_json = _units_json(tmp_path, units)
|
|
142
|
+
result = plan_segments(units_json, tmp_path / "video", oob_llm)
|
|
143
|
+
for seg in result[1]:
|
|
144
|
+
assert seg.lines_end <= n - 1
|
|
145
|
+
assert seg.lines_start >= 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_mermaid_null_for_non_diagram_types(tmp_path):
|
|
149
|
+
def mermaid_llm(messages, call_type="segments"):
|
|
150
|
+
return json.dumps(
|
|
151
|
+
[
|
|
152
|
+
{
|
|
153
|
+
"lines_start": 0,
|
|
154
|
+
"lines_end": 0,
|
|
155
|
+
"visual_type": "hook_question",
|
|
156
|
+
"title": "Open",
|
|
157
|
+
"body": None,
|
|
158
|
+
"code": None,
|
|
159
|
+
"language": None,
|
|
160
|
+
"mermaid": "classDiagram\n A --> B",
|
|
161
|
+
"left": None,
|
|
162
|
+
"right": None,
|
|
163
|
+
"rows": None,
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"lines_start": 1,
|
|
167
|
+
"lines_end": 2,
|
|
168
|
+
"visual_type": "key_insight",
|
|
169
|
+
"title": "Key",
|
|
170
|
+
"body": None,
|
|
171
|
+
"code": None,
|
|
172
|
+
"language": None,
|
|
173
|
+
"mermaid": "classDiagram\n A --> B",
|
|
174
|
+
"left": None,
|
|
175
|
+
"right": None,
|
|
176
|
+
"rows": None,
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
"lines_start": 3,
|
|
180
|
+
"lines_end": 3,
|
|
181
|
+
"visual_type": "memory_hook",
|
|
182
|
+
"title": "Rem",
|
|
183
|
+
"body": None,
|
|
184
|
+
"code": None,
|
|
185
|
+
"language": None,
|
|
186
|
+
"mermaid": None,
|
|
187
|
+
"left": None,
|
|
188
|
+
"right": None,
|
|
189
|
+
"rows": None,
|
|
190
|
+
},
|
|
191
|
+
]
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
units = [_make_unit_entry(1, n_lines=4)]
|
|
195
|
+
units_json = _units_json(tmp_path, units)
|
|
196
|
+
result = plan_segments(units_json, tmp_path / "video", mermaid_llm)
|
|
197
|
+
for seg in result[1]:
|
|
198
|
+
if seg.visual_type != "diagram":
|
|
199
|
+
assert seg.mermaid is None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# Gap filling
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_gap_filled_with_key_insight(tmp_path):
|
|
208
|
+
n = 8
|
|
209
|
+
|
|
210
|
+
def gap_llm(messages, call_type="segments"):
|
|
211
|
+
return json.dumps(
|
|
212
|
+
[
|
|
213
|
+
{
|
|
214
|
+
"lines_start": 0,
|
|
215
|
+
"lines_end": 0,
|
|
216
|
+
"visual_type": "hook_question",
|
|
217
|
+
"title": "Open",
|
|
218
|
+
"body": None,
|
|
219
|
+
"code": None,
|
|
220
|
+
"language": None,
|
|
221
|
+
"mermaid": None,
|
|
222
|
+
"left": None,
|
|
223
|
+
"right": None,
|
|
224
|
+
"rows": None,
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
"lines_start": 1,
|
|
228
|
+
"lines_end": 2,
|
|
229
|
+
"visual_type": "key_insight",
|
|
230
|
+
"title": "Key",
|
|
231
|
+
"body": None,
|
|
232
|
+
"code": None,
|
|
233
|
+
"language": None,
|
|
234
|
+
"mermaid": None,
|
|
235
|
+
"left": None,
|
|
236
|
+
"right": None,
|
|
237
|
+
"rows": None,
|
|
238
|
+
},
|
|
239
|
+
]
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
units = [_make_unit_entry(1, n_lines=n)]
|
|
243
|
+
units_json = _units_json(tmp_path, units)
|
|
244
|
+
result = plan_segments(units_json, tmp_path / "video", gap_llm)
|
|
245
|
+
segs = result[1]
|
|
246
|
+
covered = set()
|
|
247
|
+
for seg in segs:
|
|
248
|
+
covered.update(range(seg.lines_start, seg.lines_end + 1))
|
|
249
|
+
assert covered == set(range(n))
|
|
250
|
+
gap_segs = [s for s in segs if s.lines_start >= 3]
|
|
251
|
+
assert any(s.visual_type == "key_insight" for s in gap_segs)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_single_line_unit_fallback_has_two_segments(tmp_path):
|
|
255
|
+
units = [_make_unit_entry(1, n_lines=1)]
|
|
256
|
+
units_json = _units_json(tmp_path, units)
|
|
257
|
+
|
|
258
|
+
def bad_llm(messages, call_type="segments"):
|
|
259
|
+
return "not json"
|
|
260
|
+
|
|
261
|
+
result = plan_segments(units_json, tmp_path / "video", bad_llm)
|
|
262
|
+
segs = result[1]
|
|
263
|
+
assert len(segs) == 2
|
|
264
|
+
assert segs[0].visual_type == "hook_question"
|
|
265
|
+
assert segs[1].visual_type == "memory_hook"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_two_line_unit_fallback_covers_both(tmp_path):
|
|
269
|
+
units = [_make_unit_entry(1, n_lines=2)]
|
|
270
|
+
units_json = _units_json(tmp_path, units)
|
|
271
|
+
|
|
272
|
+
def bad_llm(messages, call_type="segments"):
|
|
273
|
+
return "not json"
|
|
274
|
+
|
|
275
|
+
result = plan_segments(units_json, tmp_path / "video", bad_llm)
|
|
276
|
+
segs = result[1]
|
|
277
|
+
covered = set()
|
|
278
|
+
for seg in segs:
|
|
279
|
+
covered.update(range(seg.lines_start, seg.lines_end + 1))
|
|
280
|
+
assert covered == {0, 1}
|