feat: Ausschnitte an Satzgrenzen erweitern (--exakt schneidet genau)

Bei -s/-e werden angeschnittene Sätze an beiden Enden vollständig
aufgenommen (höchstens 30 s über den Zeitraum hinaus). rede_korrigieren.py
weist das Modell bei Ausschnitten zusätzlich an, nichts zu ergänzen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-11 19:34:36 +02:00
commit faea4bf465
3 changed files with 234 additions and 28 deletions

View file

@ -147,3 +147,21 @@ def test_time_range_order_error(monkeypatch, capsys):
run_main(monkeypatch, "-u", URL, "-s", "2:00", "-e", "1:00")
assert exc.value.code == 2
assert "muss vor -e/--ende liegen" in capsys.readouterr().err
CUT_VTT = (
"WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nGuten Abend und\n\n"
"00:00:02.000 --> 00:00:03.000\nwillkommen. Schön, dass\n\n"
"00:00:03.000 --> 00:00:04.000\nSie da sind.\n"
)
@pytest.mark.parametrize("exact, text", [
([], "Guten Abend und\nwillkommen. Schön, dass\nSie da sind.\n"),
(["--exakt"], "willkommen. Schön, dass\n"),
])
def test_time_range_sentence_bounds(fake_yt_dlp, tmp_path, monkeypatch, exact, text):
monkeypatch.setattr(yt.subprocess, "run", fake_yt_dlp(GERMAN_VIDEO, vtt=CUT_VTT))
target = tmp_path / "out.txt"
run_main(monkeypatch, "-u", URL, "-s", "2", "-e", "3", "-o", target, *exact)
assert target.read_text(encoding="utf-8") == text

View file

@ -615,3 +615,36 @@ def test_ollama_open_passes_http_errors(monkeypatch):
monkeypatch.setattr(rk.urllib.request, "urlopen", not_found)
with pytest.raises(rk.urllib.error.HTTPError):
rk.ollama_open(rk.OLLAMA_URL)
# === Excerpts: sentence bounds and prompt note ===
@pytest.mark.parametrize("lang", ["de", "en"])
def test_build_system_excerpt_note(lang):
prompts = rk.PROMPTS[lang]
assert prompts["excerpt"] not in rk.build_system(["X"], lang)
system = rk.build_system(["X"], lang, excerpt=True)
assert system.startswith(prompts["system"])
assert system.index(prompts["excerpt"]) < system.index(prompts["glossary"])
assert rk.build_system([], lang, excerpt=True).endswith(prompts["excerpt"] + "\n")
CUT_VTT = (
"WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nDer Kremmel ist\n\n"
"00:00:02.000 --> 00:00:03.000\nin Moskau. Das ist\n\n"
"00:00:03.000 --> 00:00:04.000\nbekannt.\n"
)
@pytest.mark.parametrize("exact, raw", [
([], "Der Kremmel ist\nin Moskau. Das ist\nbekannt.\n"),
(["--exakt"], "in Moskau. Das ist\n"),
])
def test_main_url_excerpt(fake_yt_dlp, yt_dlp_installed, tmp_path, monkeypatch, exact, raw):
monkeypatch.setattr(subprocess, "run", fake_yt_dlp(VIDEO, vtt=CUT_VTT))
systems = recording_model(monkeypatch)
target = tmp_path / "rede.korrigiert.txt"
run_main(monkeypatch, "-u", URL, "-s", "2", "-e", "3", "-o", target, *exact)
assert (tmp_path / "rede.txt").read_text(encoding="utf-8") == raw
assert rk.PROMPTS["de"]["excerpt"] in systems[0]

View file

@ -188,17 +188,17 @@ def test_time_suffix(start, end, suffix):
assert vtt2txt.time_suffix(start, end) == suffix
def test_vtt_to_lines_time_range():
def test_vtt_to_lines_exact_time_range():
vtt = "WEBVTT\n\n" + cue(1, "A") + cue(2, "A", "B") + cue(3, "B", "C") + cue(4, "C", "D")
assert vtt2txt.vtt_to_lines(vtt, start=2, end=4) == ["B", "C"]
assert vtt2txt.vtt_to_lines(vtt, start=3) == ["C", "D"]
assert vtt2txt.vtt_to_lines(vtt, end=2) == ["A"]
assert vtt2txt.vtt_to_lines(vtt, start=2, end=4, exact=True) == ["B", "C"]
assert vtt2txt.vtt_to_lines(vtt, start=3, exact=True) == ["C", "D"]
assert vtt2txt.vtt_to_lines(vtt, end=2, exact=True) == ["A"]
def test_vtt_to_lines_time_range_skips_window_from_before_start():
# "B" was said before the range; its repetition in the first cue must not count
vtt = "WEBVTT\n\n" + cue(1, "A", "B") + cue(2, "B", "C")
assert vtt2txt.vtt_to_lines(vtt, start=2) == ["C"]
assert vtt2txt.vtt_to_lines(vtt, start=2, exact=True) == ["C"]
def test_main_time_range_default_name(sample_copy, monkeypatch):
@ -225,3 +225,70 @@ def test_cli_time_errors(sample_copy, monkeypatch, capsys, args, message):
run_main(monkeypatch, "-i", sample_copy, *args)
assert exc.value.code == 2
assert message in capsys.readouterr().err
def timed_cue(seconds, *lines):
return f"{vtt2txt.format_time(seconds)}.000 --> {vtt2txt.format_time(seconds + 1)}.000\n" \
+ "\n".join(lines) + "\n\n"
def captions(*cues):
"""VTT with one new line per cue: cues are (seconds, text)."""
return "WEBVTT\n\n" + "".join(timed_cue(t, text) for t, text in cues)
SPEECH = captions(
(100, "Erster Satz ist hier."), (102, "Der zweite Satz"), (104, "beginnt vorher. Dann"),
(106, "kommt der dritte"), (108, "und endet. Der vierte"), (110, "läuft weiter"),
(112, "und endet auch. Schluss"), (114, "folgt."),
)
def test_snap_extends_both_sides_to_sentences():
# Range holds only "kommt der dritte" and "und endet. Der vierte"
assert vtt2txt.vtt_to_lines(SPEECH, start=106, end=110) == [
"Dann", "kommt der dritte", "und endet. Der vierte", "läuft weiter",
"und endet auch.",
]
def test_snap_keeps_range_already_at_sentence_bounds():
assert vtt2txt.vtt_to_lines(SPEECH, start=102, end=104) == [
"Der zweite Satz", "beginnt vorher.",
]
assert vtt2txt.vtt_to_lines(SPEECH, start=100, end=102) == ["Erster Satz ist hier."]
def test_snap_reaches_start_and_end_of_captions():
assert vtt2txt.vtt_to_lines(SPEECH, start=114) == ["Schluss", "folgt."]
speech = captions((0, "ohne Satzzeichen"), (2, "am Anfang. Rest"))
assert vtt2txt.vtt_to_lines(speech, start=2) == ["ohne Satzzeichen", "am Anfang. Rest"]
def test_snap_cuts_exactly_without_sentence_end_in_reach():
far = vtt2txt.MAX_SNAP_SECONDS + 5
speech = captions(
(0, "Satz."), (2, "ein endloser"), (far, "Satz ohne"), (2 * far, "Ende"), (3 * far, "jetzt."),
)
assert vtt2txt.vtt_to_lines(speech, start=far, end=far + 1) == ["Satz ohne"]
def test_snap_exact_keeps_cut():
assert vtt2txt.vtt_to_lines(SPEECH, start=106, end=110, exact=True) == [
"kommt der dritte", "und endet. Der vierte",
]
def test_snap_closing_quote_belongs_to_sentence():
speech = captions((0, "Er sagte „Nein.“ Und"), (2, "dann"), (4, "ging er."))
assert vtt2txt.vtt_to_lines(speech, start=2, end=4) == ["Und", "dann", "ging er."]
def test_main_exact_option(tmp_path, monkeypatch):
source = tmp_path / "rede.vtt"
source.write_text(SPEECH, encoding="utf-8")
run_main(monkeypatch, "-i", source, "-s", "106", "-e", "110", "--exakt")
target = tmp_path / "rede_00-01-46_bis_00-01-50.txt"
assert target.read_text(encoding="utf-8") == "kommt der dritte\nund endet. Der vierte\n"
run_main(monkeypatch, "-i", source, "-s", "106", "-e", "110")
assert target.read_text(encoding="utf-8").endswith("und endet auch.\n")