learnx-cli 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. learnx_cli-0.3.0.dist-info/METADATA +240 -0
  2. learnx_cli-0.3.0.dist-info/RECORD +131 -0
  3. learnx_cli-0.3.0.dist-info/WHEEL +4 -0
  4. learnx_cli-0.3.0.dist-info/entry_points.txt +2 -0
  5. tutor/.env copy.example +4 -0
  6. tutor/__init__.py +0 -0
  7. tutor/__main__.py +4 -0
  8. tutor/assets/__init__.py +5 -0
  9. tutor/assets/html/fonts/Inter-Bold.woff2 +0 -0
  10. tutor/assets/html/fonts/Inter-Regular.woff2 +0 -0
  11. tutor/assets/html/fonts/Inter-SemiBold.woff2 +0 -0
  12. tutor/assets/html/fonts/JetBrainsMono-Regular.woff2 +0 -0
  13. tutor/assets/html/highlight-java.min.js +2 -0
  14. tutor/assets/html/highlight-javascript.min.js +2 -0
  15. tutor/assets/html/highlight-python.min.js +2 -0
  16. tutor/assets/html/highlight.min.js +17 -0
  17. tutor/assets/html/mermaid.min.js +31 -0
  18. tutor/assets/html/slide_base.css +464 -0
  19. tutor/assets/html/theme-learnx-dark.css +12 -0
  20. tutor/audio/__init__.py +0 -0
  21. tutor/audio/audio_builder.py +143 -0
  22. tutor/audio/sanitizer.py +9 -0
  23. tutor/audio/tts_renderer.py +54 -0
  24. tutor/cli/__init__.py +0 -0
  25. tutor/cli/commands.py +391 -0
  26. tutor/cli/logo.py +21 -0
  27. tutor/cli/playback_commands.py +239 -0
  28. tutor/cli/shell.py +91 -0
  29. tutor/cli/shell_context.py +18 -0
  30. tutor/cli/theme.py +39 -0
  31. tutor/cli/video_commands.py +123 -0
  32. tutor/config.py +122 -0
  33. tutor/conftest.py +5 -0
  34. tutor/constants.py +82 -0
  35. tutor/exceptions.py +26 -0
  36. tutor/generation/__init__.py +0 -0
  37. tutor/generation/assembler.py +81 -0
  38. tutor/generation/curriculum.py +97 -0
  39. tutor/generation/dialogue.py +172 -0
  40. tutor/generation/narrator.py +122 -0
  41. tutor/generation/segment_parser.py +223 -0
  42. tutor/generation/segment_planner.py +200 -0
  43. tutor/generation/visual_planner.py +205 -0
  44. tutor/infra/__init__.py +0 -0
  45. tutor/infra/llm.py +152 -0
  46. tutor/ingestion/__init__.py +0 -0
  47. tutor/ingestion/chunker.py +171 -0
  48. tutor/ingestion/doc_analyzer.py +41 -0
  49. tutor/ingestion/parse_content.py +19 -0
  50. tutor/ingestion/summarizer.py +51 -0
  51. tutor/inspector.py +117 -0
  52. tutor/llm_config.toml +58 -0
  53. tutor/models.py +147 -0
  54. tutor/player/__init__.py +0 -0
  55. tutor/player/input_handler.py +45 -0
  56. tutor/player/player.py +308 -0
  57. tutor/player/player_display.py +117 -0
  58. tutor/prompts/curriculum.txt +67 -0
  59. tutor/prompts/dialogue.txt +62 -0
  60. tutor/prompts/narrate.txt +34 -0
  61. tutor/prompts/qa.txt +17 -0
  62. tutor/prompts/summarize.txt +9 -0
  63. tutor/prompts/visual.txt +60 -0
  64. tutor/prompts/visual_v3.txt +91 -0
  65. tutor/qa/__init__.py +0 -0
  66. tutor/qa/qa.py +105 -0
  67. tutor/requirements-dev.txt +2 -0
  68. tutor/requirements.txt +12 -0
  69. tutor/sample_docs/headingless_large.md +1 -0
  70. tutor/sample_docs/headingless_test.md +1 -0
  71. tutor/sample_docs/java-basics.md +78 -0
  72. tutor/tests/__init__.py +0 -0
  73. tutor/tests/audio/__init__.py +0 -0
  74. tutor/tests/audio/test_audio_builder.py +106 -0
  75. tutor/tests/audio/test_sanitizer.py +41 -0
  76. tutor/tests/cli/__init__.py +0 -0
  77. tutor/tests/cli/test_commands.py +67 -0
  78. tutor/tests/cli/test_video_commands.py +190 -0
  79. tutor/tests/e2e/README.md +61 -0
  80. tutor/tests/e2e/__init__.py +0 -0
  81. tutor/tests/e2e/conftest.py +117 -0
  82. tutor/tests/e2e/fixtures/README.md +17 -0
  83. tutor/tests/e2e/fixtures/sample.md +13 -0
  84. tutor/tests/e2e/test_audio_quality.py +40 -0
  85. tutor/tests/e2e/test_av_sync.py +56 -0
  86. tutor/tests/e2e/test_pipeline_smoke.py +37 -0
  87. tutor/tests/e2e/test_slide_render.py +72 -0
  88. tutor/tests/e2e/test_video_streams.py +104 -0
  89. tutor/tests/generation/__init__.py +0 -0
  90. tutor/tests/generation/conftest.py +134 -0
  91. tutor/tests/generation/test_assembler.py +64 -0
  92. tutor/tests/generation/test_curriculum.py +107 -0
  93. tutor/tests/generation/test_narrator.py +165 -0
  94. tutor/tests/generation/test_segment_edge_cases.py +280 -0
  95. tutor/tests/generation/test_segment_planner.py +324 -0
  96. tutor/tests/generation/test_visual_planner.py +319 -0
  97. tutor/tests/ingestion/__init__.py +0 -0
  98. tutor/tests/ingestion/test_chunker.py +94 -0
  99. tutor/tests/ingestion/test_doc_analyzer.py +51 -0
  100. tutor/tests/player/__init__.py +0 -0
  101. tutor/tests/player/test_player_states.py +88 -0
  102. tutor/tests/test_assets.py +39 -0
  103. tutor/tests/test_models_visual.py +180 -0
  104. tutor/tests/visual/__init__.py +0 -0
  105. tutor/tests/visual/test_beat_timer.py +321 -0
  106. tutor/tests/visual/test_pipeline_integration.py +178 -0
  107. tutor/tests/visual/test_slide_renderer.py +298 -0
  108. tutor/tests/visual/test_subtitle_writer.py +165 -0
  109. tutor/tests/visual/test_video_assembler.py +108 -0
  110. tutor/tests/visual/test_visual_pipeline.py +270 -0
  111. tutor/tutor.py +365 -0
  112. tutor/visual/__init__.py +213 -0
  113. tutor/visual/beat_timer.py +222 -0
  114. tutor/visual/slide_renderer.py +236 -0
  115. tutor/visual/subtitle_writer.py +187 -0
  116. tutor/visual/templates/_base.html.j2 +40 -0
  117. tutor/visual/templates/analogy.html.j2 +21 -0
  118. tutor/visual/templates/callout.html.j2 +10 -0
  119. tutor/visual/templates/code_example.html.j2 +12 -0
  120. tutor/visual/templates/comparison.html.j2 +28 -0
  121. tutor/visual/templates/decision_guide.html.j2 +37 -0
  122. tutor/visual/templates/definition.html.j2 +13 -0
  123. tutor/visual/templates/diagram.html.j2 +11 -0
  124. tutor/visual/templates/hook_question.html.j2 +17 -0
  125. tutor/visual/templates/key_insight.html.j2 +9 -0
  126. tutor/visual/templates/memory_hook.html.j2 +7 -0
  127. tutor/visual/templates/outro.html.j2 +16 -0
  128. tutor/visual/templates/question_prompt.html.j2 +13 -0
  129. tutor/visual/templates/step_sequence.html.j2 +14 -0
  130. tutor/visual/templates/title_card.html.j2 +12 -0
  131. tutor/visual/video_assembler.py +299 -0
@@ -0,0 +1,13 @@
1
+ # What is a Variable?
2
+
3
+ A variable is a named container that holds a value in a computer program.
4
+ Think of it like a labeled box: you put something inside the box, give the
5
+ box a name, and later you can find what you stored by using that name.
6
+
7
+ For example, in Python you might write `age = 25`. This creates a variable
8
+ called `age` and stores the number 25 in it. Later, when your program needs
9
+ to know the age, it looks inside the `age` box and finds 25.
10
+
11
+ Variables can hold different types of data: numbers, text, lists, or more
12
+ complex structures. The type determines what operations you can perform on
13
+ the value — you can add two numbers, but you cannot add a number to a sentence.
@@ -0,0 +1,40 @@
1
+ """Audio quality: not silent, duration reasonable, correct sample rate."""
2
+
3
+ from pydub import AudioSegment
4
+
5
+
6
+ def test_audio_not_silent(pipeline_output):
7
+ """Assert tutorial.mp3 is not silent (dBFS > -60)."""
8
+ mp3 = pipeline_output / "tutorial.mp3"
9
+ audio = AudioSegment.from_mp3(str(mp3))
10
+ assert audio.dBFS > -60, (
11
+ f"tutorial.mp3 appears silent: dBFS={audio.dBFS:.1f} (threshold: -60 dBFS)"
12
+ )
13
+
14
+
15
+ def test_audio_duration_positive(pipeline_output):
16
+ """Assert tutorial.mp3 has a positive duration in milliseconds."""
17
+ mp3 = pipeline_output / "tutorial.mp3"
18
+ audio = AudioSegment.from_mp3(str(mp3))
19
+ assert len(audio) > 0, "tutorial.mp3 has zero duration"
20
+
21
+
22
+ def test_audio_duration_matches_fixture_length(pipeline_output):
23
+ """Assert tutorial.mp3 is at least 10 seconds long for a 3-paragraph fixture."""
24
+ mp3 = pipeline_output / "tutorial.mp3"
25
+ audio = AudioSegment.from_mp3(str(mp3))
26
+ assert len(audio) > 10_000, (
27
+ f"tutorial.mp3 too short: {len(audio)}ms (expected > 10000ms for a 3-paragraph fixture)"
28
+ )
29
+
30
+
31
+ def test_unit_audio_not_silent(pipeline_output):
32
+ """Assert each unit_*.mp3 is not silent (dBFS > -60)."""
33
+ units_dir = pipeline_output / "tutorial_units"
34
+ unit_files = sorted(units_dir.glob("unit_*.mp3"))
35
+ assert unit_files, f"No unit_*.mp3 found in {units_dir}"
36
+ for unit_path in unit_files:
37
+ audio = AudioSegment.from_mp3(str(unit_path))
38
+ assert audio.dBFS > -60, (
39
+ f"{unit_path.name} appears silent: dBFS={audio.dBFS:.1f} (threshold: -60 dBFS)"
40
+ )
@@ -0,0 +1,56 @@
1
+ """A/V sync: timing.json per-unit end_ms vs unit MP3 duration drift < 500ms."""
2
+
3
+ import json
4
+
5
+ import pytest
6
+ from pydub import AudioSegment
7
+
8
+ from tutor.constants import SILENCE_BREATH_MS, SILENCE_TURN_MS
9
+
10
+
11
+ def test_timing_units_nonempty(pipeline_output):
12
+ """Assert timing.json contains at least one unit entry."""
13
+ timing_path = pipeline_output / "tutorial.timing.json"
14
+ timing = json.loads(timing_path.read_text(encoding="utf-8"))
15
+ assert timing["units"], "timing.json 'units' dict is empty — no timing data captured"
16
+
17
+
18
+ def test_timing_end_matches_audio_duration(pipeline_output):
19
+ """Assert per-unit last end_ms vs unit MP3 duration drift is under 500ms."""
20
+ timing_path = pipeline_output / "tutorial.timing.json"
21
+ timing = json.loads(timing_path.read_text(encoding="utf-8"))
22
+ units_dir = pipeline_output / "tutorial_units"
23
+
24
+ for unit_key, entries in timing["units"].items():
25
+ if not entries:
26
+ continue
27
+ last_end_ms = entries[-1]["end_ms"]
28
+ unit_num = int(unit_key)
29
+ unit_mp3 = units_dir / f"unit_{unit_num:02d}.mp3"
30
+
31
+ if not unit_mp3.exists():
32
+ pytest.skip(f"Unit MP3 not found: {unit_mp3}")
33
+
34
+ audio_duration_ms = len(AudioSegment.from_mp3(str(unit_mp3)))
35
+ drift = abs(last_end_ms - audio_duration_ms)
36
+ assert drift < 500, (
37
+ f"Unit {unit_key}: timing end_ms={last_end_ms}ms vs "
38
+ f"audio duration={audio_duration_ms}ms (drift={drift}ms, threshold=500ms)"
39
+ )
40
+
41
+
42
+ def test_no_timing_gaps(pipeline_output):
43
+ """Assert consecutive timing entries have only BREATH or TURN silence gaps."""
44
+ timing_path = pipeline_output / "tutorial.timing.json"
45
+ timing = json.loads(timing_path.read_text(encoding="utf-8"))
46
+
47
+ for unit_key, entries in timing["units"].items():
48
+ for i in range(1, len(entries)):
49
+ prev_speaker = entries[i - 1]["speaker"]
50
+ curr_speaker = entries[i]["speaker"]
51
+ gap = entries[i]["start_ms"] - entries[i - 1]["end_ms"]
52
+ expected = SILENCE_BREATH_MS if prev_speaker == curr_speaker else SILENCE_TURN_MS
53
+ assert gap == expected, (
54
+ f"Unit {unit_key}, entry {i}: gap={gap}ms, "
55
+ f"expected {expected}ms ({prev_speaker}→{curr_speaker})"
56
+ )
@@ -0,0 +1,37 @@
1
+ """Smoke test: full pipeline runs without crash and produces expected output files."""
2
+
3
+ import json
4
+
5
+
6
+ def test_pipeline_exits_zero(pipeline_output):
7
+ """Assert the pipeline ran successfully and created the output directory."""
8
+ assert pipeline_output.exists(), f"Output directory not created: {pipeline_output}"
9
+
10
+
11
+ def test_mp3_exists_and_nonempty(pipeline_output):
12
+ """Assert tutorial.mp3 exists and has non-zero size."""
13
+ mp3 = pipeline_output / "tutorial.mp3"
14
+ assert mp3.exists(), f"tutorial.mp3 not found in {pipeline_output}"
15
+ assert mp3.stat().st_size > 0, "tutorial.mp3 is empty"
16
+
17
+
18
+ def test_timing_json_exists(pipeline_output):
19
+ """Assert tutorial.timing.json was written to the output directory."""
20
+ timing = pipeline_output / "tutorial.timing.json"
21
+ assert timing.exists(), f"tutorial.timing.json not found in {pipeline_output}"
22
+
23
+
24
+ def test_timing_json_is_valid(pipeline_output):
25
+ """Assert tutorial.timing.json is valid JSON with 'version' and 'units' keys."""
26
+ timing_path = pipeline_output / "tutorial.timing.json"
27
+ data = json.loads(timing_path.read_text(encoding="utf-8"))
28
+ assert "version" in data, "timing.json missing 'version' key"
29
+ assert "units" in data, "timing.json missing 'units' key"
30
+
31
+
32
+ def test_unit_mp3s_exist(pipeline_output):
33
+ """Assert at least one unit_*.mp3 file exists in tutorial_units/."""
34
+ units_dir = pipeline_output / "tutorial_units"
35
+ assert units_dir.exists(), f"tutorial_units/ directory not found in {pipeline_output}"
36
+ unit_files = list(units_dir.glob("unit_*.mp3"))
37
+ assert len(unit_files) >= 1, f"No unit_*.mp3 files found in {units_dir}"
@@ -0,0 +1,72 @@
1
+ """Slide render: Playwright loads HTML slides, page is not blank, no error messages."""
2
+
3
+ import pathlib
4
+ import tempfile
5
+
6
+ import pytest
7
+
8
+ SLIDE_DIR = pathlib.Path(tempfile.gettempdir()) / "learnx_e2e_smoke" / "slides"
9
+ SCREENSHOT_PATH = pathlib.Path(tempfile.gettempdir()) / "learnx_e2e_smoke" / "slide_01.png"
10
+
11
+
12
+ def _first_slide():
13
+ """Return the first HTML slide path; fail clearly if slides/ is empty."""
14
+ html_files = sorted(SLIDE_DIR.glob("*.html"))
15
+ assert html_files, f"No .html files found in {SLIDE_DIR}"
16
+ return html_files[0]
17
+
18
+
19
+ @pytest.fixture(scope="module")
20
+ def browser_page(pipeline_output):
21
+ """Launch a Playwright Chromium browser and yield a reusable page object."""
22
+ if not SLIDE_DIR.exists():
23
+ pytest.skip("slides/ directory not present — visual pipeline not run")
24
+ from playwright.sync_api import sync_playwright
25
+
26
+ with sync_playwright() as p:
27
+ browser = p.chromium.launch()
28
+ page = browser.new_page(viewport={"width": 1280, "height": 720})
29
+ yield page
30
+ browser.close()
31
+
32
+
33
+ def test_at_least_one_slide_exists(pipeline_output):
34
+ """Assert the slides/ directory contains at least one .html file."""
35
+ if not SLIDE_DIR.exists():
36
+ pytest.skip("slides/ directory not present — visual pipeline not run")
37
+ html_files = list(SLIDE_DIR.glob("*.html"))
38
+ assert html_files, f"No .html files found in {SLIDE_DIR}"
39
+
40
+
41
+ def test_slide_page_not_blank(browser_page):
42
+ """Assert the first slide's page content exceeds 500 characters."""
43
+ browser_page.goto(_first_slide().as_uri())
44
+ content = browser_page.content()
45
+ assert len(content) > 500, (
46
+ f"Slide page appears blank: content length={len(content)} (expected > 500)"
47
+ )
48
+
49
+
50
+ def test_slide_has_visible_text(browser_page):
51
+ """Assert the first slide has non-empty visible body text."""
52
+ browser_page.goto(_first_slide().as_uri())
53
+ text = browser_page.locator("body").inner_text().strip()
54
+ assert text, "Slide body contains no visible text"
55
+
56
+
57
+ def test_slide_no_error_messages(browser_page):
58
+ """Assert no 'Error' or 'TypeError' text appears in the slide body."""
59
+ browser_page.goto(_first_slide().as_uri())
60
+ text = browser_page.locator("body").inner_text()
61
+ assert "Error" not in text, "Slide contains 'Error' in visible text"
62
+ assert "TypeError" not in text, "Slide contains 'TypeError' in visible text"
63
+
64
+
65
+ def test_slide_screenshot_saved(browser_page):
66
+ """Assert a non-blank screenshot can be taken and saved from the first slide."""
67
+ browser_page.goto(_first_slide().as_uri())
68
+ browser_page.screenshot(path=str(SCREENSHOT_PATH))
69
+ assert SCREENSHOT_PATH.exists(), f"Screenshot not saved: {SCREENSHOT_PATH}"
70
+ assert SCREENSHOT_PATH.stat().st_size > 5000, (
71
+ f"Screenshot too small ({SCREENSHOT_PATH.stat().st_size} bytes) — may be a blank image"
72
+ )
@@ -0,0 +1,104 @@
1
+ """Video stream verification: audio and video streams both present, durations non-zero."""
2
+
3
+ import json
4
+ import subprocess
5
+
6
+ import pytest
7
+
8
+
9
+ def _to_float(value, default: float = 0.0) -> float:
10
+ """Return float(value) or default when value is non-numeric (e.g. 'N/A')."""
11
+ try:
12
+ return float(value)
13
+ except (TypeError, ValueError):
14
+ return default
15
+
16
+
17
+ def _to_int(value, default: int = 0) -> int:
18
+ """Return int(value) or default when value is non-numeric (e.g. 'N/A')."""
19
+ try:
20
+ return int(value)
21
+ except (TypeError, ValueError):
22
+ return default
23
+
24
+
25
+ def ffprobe_streams(path):
26
+ """Run ffprobe on path and return the list of stream dicts."""
27
+ result = subprocess.run(
28
+ [
29
+ "ffprobe",
30
+ "-v",
31
+ "error",
32
+ "-show_entries",
33
+ "stream=codec_type,duration,bit_rate",
34
+ "-of",
35
+ "json",
36
+ str(path),
37
+ ],
38
+ capture_output=True,
39
+ text=True,
40
+ timeout=30,
41
+ )
42
+ if result.returncode != 0:
43
+ pytest.fail(f"ffprobe failed (rc={result.returncode}): {result.stderr.strip()}")
44
+ return json.loads(result.stdout)["streams"]
45
+
46
+
47
+ def _get_mp4(pipeline_output):
48
+ """Return the expected tutorial.mp4 path inside pipeline_output."""
49
+ return pipeline_output / "tutorial.mp4"
50
+
51
+
52
+ def test_video_file_exists(pipeline_output):
53
+ """Assert tutorial.mp4 exists and has non-zero size; skip if absent."""
54
+ mp4 = _get_mp4(pipeline_output)
55
+ if not mp4.exists():
56
+ pytest.skip("tutorial.mp4 not present — video pipeline not run")
57
+ assert mp4.stat().st_size > 0, "tutorial.mp4 is empty"
58
+
59
+
60
+ def test_video_stream_present(pipeline_output):
61
+ """Assert tutorial.mp4 contains at least one video stream."""
62
+ mp4 = _get_mp4(pipeline_output)
63
+ if not mp4.exists():
64
+ pytest.skip("tutorial.mp4 not present — video pipeline not run")
65
+ streams = ffprobe_streams(mp4)
66
+ codec_types = [s.get("codec_type") for s in streams]
67
+ assert "video" in codec_types, f"No video stream found in tutorial.mp4; streams: {codec_types}"
68
+
69
+
70
+ def test_audio_stream_present(pipeline_output):
71
+ """Assert tutorial.mp4 contains an audio stream — catches the silent-video bug."""
72
+ mp4 = _get_mp4(pipeline_output)
73
+ if not mp4.exists():
74
+ pytest.skip("tutorial.mp4 not present — video pipeline not run")
75
+ streams = ffprobe_streams(mp4)
76
+ codec_types = [s.get("codec_type") for s in streams]
77
+ assert "audio" in codec_types, (
78
+ f"No audio stream found in tutorial.mp4 — this is the silent-video bug; "
79
+ f"streams: {codec_types}"
80
+ )
81
+
82
+
83
+ def test_audio_stream_duration_nonzero(pipeline_output):
84
+ """Assert the audio stream duration is greater than zero."""
85
+ mp4 = _get_mp4(pipeline_output)
86
+ if not mp4.exists():
87
+ pytest.skip("tutorial.mp4 not present — video pipeline not run")
88
+ streams = ffprobe_streams(mp4)
89
+ audio_streams = [s for s in streams if s.get("codec_type") == "audio"]
90
+ assert audio_streams, "No audio stream found"
91
+ duration = _to_float(audio_streams[0].get("duration"))
92
+ assert duration > 0, f"Audio stream has zero duration: {duration}"
93
+
94
+
95
+ def test_audio_stream_not_muted(pipeline_output):
96
+ """Assert audio stream bitrate is non-zero, ruling out a muted stream."""
97
+ mp4 = _get_mp4(pipeline_output)
98
+ if not mp4.exists():
99
+ pytest.skip("tutorial.mp4 not present — video pipeline not run")
100
+ streams = ffprobe_streams(mp4)
101
+ audio_streams = [s for s in streams if s.get("codec_type") == "audio"]
102
+ assert audio_streams, "No audio stream found"
103
+ bit_rate = _to_int(audio_streams[0].get("bit_rate"))
104
+ assert bit_rate > 0, f"Audio stream bitrate is zero — stream may be silent: {bit_rate}"
File without changes
@@ -0,0 +1,134 @@
1
+ """Shared fixtures and helpers for segment planner tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from tutor.models import DialogueLine
11
+
12
+ N_LINES = 6
13
+
14
+
15
+ def _line(i: int, speaker: str = "ALEX", unit: int = 1) -> DialogueLine:
16
+ return DialogueLine(speaker=speaker, text=f"Line {i}", unit_number=unit)
17
+
18
+
19
+ def _make_lines(n: int = N_LINES, unit: int = 1) -> list[DialogueLine]:
20
+ speakers = ["ALEX", "MAYA", "ALEX", "MAYA", "ALEX", "ALEX"]
21
+ return [_line(i, speakers[i % len(speakers)], unit) for i in range(n)]
22
+
23
+
24
+ def _make_unit_entry(
25
+ unit_num: int = 1, concept: str = "Test Concept", n_lines: int = N_LINES
26
+ ) -> dict:
27
+ lines = [
28
+ {"speaker": "ALEX" if i % 2 == 0 else "MAYA", "text": f"Line {i}", "unit_number": unit_num}
29
+ for i in range(n_lines)
30
+ ]
31
+ return {
32
+ "unit": unit_num,
33
+ "concept": concept,
34
+ "lines": lines,
35
+ "source_sections": [],
36
+ "complexity": 1,
37
+ "word_budget": 200,
38
+ "key_facts": [],
39
+ "common_misconception": "",
40
+ "good_analogy": "",
41
+ "question_style": "",
42
+ "memory_hook": "",
43
+ }
44
+
45
+
46
+ def _units_json(tmp_path: Path, units: list[dict]) -> Path:
47
+ p = tmp_path / "tutorial.units.json"
48
+ p.write_text(json.dumps(units), encoding="utf-8")
49
+ return p
50
+
51
+
52
+ def _valid_response(lines: list[DialogueLine]) -> str:
53
+ n = len(lines)
54
+ mid_end = max(1, n - 2)
55
+ return json.dumps(
56
+ [
57
+ {
58
+ "lines_start": 0,
59
+ "lines_end": 0,
60
+ "visual_type": "hook_question",
61
+ "title": "Opening",
62
+ "body": None,
63
+ "code": None,
64
+ "language": None,
65
+ "mermaid": None,
66
+ "left": None,
67
+ "right": None,
68
+ "rows": None,
69
+ },
70
+ {
71
+ "lines_start": 1,
72
+ "lines_end": mid_end,
73
+ "visual_type": "key_insight",
74
+ "title": "Key Point",
75
+ "body": None,
76
+ "code": None,
77
+ "language": None,
78
+ "mermaid": None,
79
+ "left": None,
80
+ "right": None,
81
+ "rows": None,
82
+ },
83
+ {
84
+ "lines_start": mid_end + 1,
85
+ "lines_end": n - 1,
86
+ "visual_type": "memory_hook",
87
+ "title": "Remember",
88
+ "body": None,
89
+ "code": None,
90
+ "language": None,
91
+ "mermaid": None,
92
+ "left": None,
93
+ "right": None,
94
+ "rows": None,
95
+ },
96
+ ]
97
+ )
98
+
99
+
100
+ def _fake_llm(lines: list[DialogueLine]):
101
+ def _llm(messages, call_type="segments"):
102
+ return _valid_response(lines)
103
+
104
+ return _llm
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # pytest fixtures
109
+ # ---------------------------------------------------------------------------
110
+
111
+
112
+ @pytest.fixture()
113
+ def make_lines():
114
+ return _make_lines
115
+
116
+
117
+ @pytest.fixture()
118
+ def make_unit_entry():
119
+ return _make_unit_entry
120
+
121
+
122
+ @pytest.fixture()
123
+ def units_json_factory():
124
+ return _units_json
125
+
126
+
127
+ @pytest.fixture()
128
+ def fake_llm_factory():
129
+ return _fake_llm
130
+
131
+
132
+ @pytest.fixture()
133
+ def valid_response_factory():
134
+ return _valid_response
@@ -0,0 +1,64 @@
1
+ from tutor.generation.assembler import assemble
2
+ from tutor.models import DialogueLine, TeachingUnit
3
+
4
+
5
+ def _make_unit(n: int) -> TeachingUnit:
6
+ return TeachingUnit(
7
+ unit=n,
8
+ concept=f"Concept {n}",
9
+ source_sections=[f"s0{n}"],
10
+ complexity=2,
11
+ word_budget=400,
12
+ key_facts=["fact"],
13
+ common_misconception="wrong belief",
14
+ good_analogy="like a thing",
15
+ question_style="recall",
16
+ memory_hook=f"Remember concept {n}",
17
+ )
18
+
19
+
20
+ def _make_lines(unit_num: int, count: int = 4) -> list[DialogueLine]:
21
+ return [
22
+ DialogueLine(
23
+ speaker="ALEX" if i % 2 == 0 else "MAYA",
24
+ text=f"Unit {unit_num} line {i}",
25
+ unit_number=unit_num,
26
+ )
27
+ for i in range(count)
28
+ ]
29
+
30
+
31
+ def test_assemble_starts_with_alex_intro():
32
+ units = [_make_unit(1), _make_unit(2)]
33
+ result = assemble(units, [_make_lines(1), _make_lines(2)], "tutor-student", "Java Basics")
34
+ assert result[0].speaker == "ALEX"
35
+ assert result[0].unit_number == 0
36
+
37
+
38
+ def test_assemble_ends_with_outro():
39
+ units = [_make_unit(1)]
40
+ result = assemble(units, [_make_lines(1)], "tutor-student", "Java Basics")
41
+ assert result[-1].unit_number == -1
42
+
43
+
44
+ def test_assemble_transitions_between_units():
45
+ units = [_make_unit(1), _make_unit(2)]
46
+ result = assemble(units, [_make_lines(1), _make_lines(2)], "tutor-student", "Java Basics")
47
+ unit_numbers = [ln.unit_number for ln in result]
48
+ assert 1 in unit_numbers and 2 in unit_numbers
49
+
50
+
51
+ def test_assemble_outro_contains_memory_hooks():
52
+ units = [_make_unit(1), _make_unit(2)]
53
+ result = assemble(units, [_make_lines(1), _make_lines(2)], "tutor-student", "Java Basics")
54
+ outro = result[-1]
55
+ assert "Remember concept 1" in outro.text
56
+ assert "Remember concept 2" in outro.text
57
+
58
+
59
+ def test_sanitizer_applied_no_symbols():
60
+ units = [_make_unit(1)]
61
+ lines = [DialogueLine(speaker="ALEX", text="Use List<String>", unit_number=1)]
62
+ result = assemble(units, [lines], "tutor-student", "Java Basics")
63
+ for line in result:
64
+ assert "<" not in line.text, f"Symbol leak in: {line.text}"
@@ -0,0 +1,107 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from tutor.generation.curriculum import plan
6
+ from tutor.models import Chunk, DocProfile
7
+
8
+
9
+ def _make_profile() -> DocProfile:
10
+ return DocProfile(
11
+ filepath="test.md",
12
+ raw_bytes=10_000,
13
+ estimated_tokens=5_000,
14
+ strategy="B",
15
+ section_count=5,
16
+ has_code_blocks=True,
17
+ language_hint="java",
18
+ )
19
+
20
+
21
+ def _make_chunks() -> list[Chunk]:
22
+ return [
23
+ Chunk(
24
+ chunk_id=f"s0{i}",
25
+ breadcrumb=f"Section {i}",
26
+ heading=f"Section {i}",
27
+ level=2,
28
+ token_count=500,
29
+ text=f"Content about concept {i}. " * 50,
30
+ has_code=True,
31
+ summary=f"This section covers concept {i} with a practical example.",
32
+ )
33
+ for i in range(1, 5)
34
+ ]
35
+
36
+
37
+ GOOD_RESPONSE = json.dumps(
38
+ [
39
+ {
40
+ "concept": "Pass-by-Value",
41
+ "source_sections": ["s01"],
42
+ "complexity": 3,
43
+ "key_facts": ["Java passes references by value"],
44
+ "common_misconception": "Thinks Java passes objects by reference",
45
+ "good_analogy": "Copying an address, not a house",
46
+ "question_style": "predict",
47
+ "memory_hook": "Copy the address, not the house",
48
+ "prerequisite_concepts": [],
49
+ },
50
+ {
51
+ "concept": "String Equality",
52
+ "source_sections": ["s02"],
53
+ "complexity": 2,
54
+ "key_facts": ["Use .equals() not =="],
55
+ "common_misconception": "Thinks == compares content",
56
+ "good_analogy": "Two identical keys from different locksmiths",
57
+ "question_style": "error-spot",
58
+ "memory_hook": "Reference check, not content check",
59
+ "prerequisite_concepts": [],
60
+ },
61
+ ]
62
+ )
63
+
64
+
65
+ def fake_llm(messages, call_type="dialogue"):
66
+ return GOOD_RESPONSE
67
+
68
+
69
+ def test_plan_returns_teaching_units():
70
+ from tutor.models import TeachingUnit
71
+
72
+ units = plan(_make_chunks(), _make_profile(), 20, fake_llm)
73
+ assert len(units) == 2
74
+ assert all(isinstance(u, TeachingUnit) for u in units)
75
+
76
+
77
+ def test_plan_computes_word_budgets():
78
+ units = plan(_make_chunks(), _make_profile(), 20, fake_llm)
79
+ for u in units:
80
+ assert u.word_budget > 0
81
+
82
+
83
+ def test_plan_raises_on_empty_response():
84
+ from tutor.exceptions import LLMError
85
+
86
+ def empty_llm(messages, call_type="dialogue"):
87
+ return "[]"
88
+
89
+ with pytest.raises(LLMError):
90
+ plan(_make_chunks(), _make_profile(), 20, empty_llm)
91
+
92
+
93
+ def test_plan_raises_on_bad_json():
94
+ from tutor.exceptions import LLMError
95
+
96
+ def bad_llm(messages, call_type="dialogue"):
97
+ return "not json at all"
98
+
99
+ with pytest.raises(LLMError):
100
+ plan(_make_chunks(), _make_profile(), 20, bad_llm)
101
+
102
+
103
+ def test_word_budget_proportional_to_complexity():
104
+ units = plan(_make_chunks(), _make_profile(), 20, fake_llm)
105
+ # Unit 0 has complexity 3, Unit 1 has complexity 2 → ratio should be 3:2 = 1.5
106
+ ratio = units[0].word_budget / units[1].word_budget
107
+ assert 1.4 <= ratio <= 1.6