From c1d270bc0ebe642f5f9434f0e9129d2ce79fd383 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Sat, 10 Oct 2026 19:59:37 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=C3=84nderungsbericht=20Rohtext=20?= =?UTF-8?q?=E2=86=92=20Korrektur=20(aenderungen.py)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wortvergleich ohne Groß-/Kleinschreibung, Satzzeichen und „z. B.“-Varianten, damit nur echte Wortänderungen auffallen - HTML-Bericht: Änderungen nach Häufigkeit, korrigierter Text mit markierten Ersetzungen (Original dahinter), Einfügungen, Streichungen, Prüfmarken - rede_korrigieren.py schreibt den Bericht bei jedem Lauf (--kein-bericht) und zeigt die häufigsten Änderungen; Vergleich mit dem Rohtext vor Glossar - aenderungen.py als eigenes Skript für vorhandene Dateien Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + README.md | 28 ++++ aenderungen.py | 234 +++++++++++++++++++++++++++++++++ rede_korrigieren.py | 14 +- tests/test_aenderungen.py | 99 ++++++++++++++ tests/test_rede_korrigieren.py | 22 ++++ 6 files changed, 397 insertions(+), 1 deletion(-) create mode 100755 aenderungen.py create mode 100644 tests/test_aenderungen.py diff --git a/.gitignore b/.gitignore index 6c5e150..0697b2b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Ergebnisse und Zwischenstände *.korrigiert.txt *.parts/ +*.aenderungen.html __pycache__/ *.pyc diff --git a/README.md b/README.md index 8f8963e..42ad620 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Die Werkzeuge sind einzeln oder hintereinander nutzbar: | `yt_untertitel.py` | Untertitel eines YouTube-Videos als VTT herunterladen | | `vtt2txt.py` | VTT in eine Rohabschrift (Text) umwandeln | | `rede_korrigieren.py` | Rohabschrift (oder VTT direkt) mit dem Sprachmodell korrigieren | +| `aenderungen.py` | Änderungsbericht: was hat das Modell gegenüber dem Rohtext geändert? | ## Voraussetzungen @@ -132,6 +133,7 @@ werden trotzdem umgewandelt; der Exit-Code ist dann 1. | `-o`, `--output` | Ausgabedatei; Standard: `.korrigiert.txt` neben der Eingabe | | `-g`, `--glossar` | Glossar mit festen Ersetzungen und Schreibweisen; mehrfach angeben zum Kombinieren (Ersetzungen in Reihenfolge der Angabe) | | `-m`, `--model` | Ollama-Modell; Standard: `qwen3:32b` (Konstante `MODEL`) | +| `--kein-bericht` | keinen Änderungsbericht schreiben | ## Ablauf @@ -147,8 +149,32 @@ werden trotzdem umgewandelt; der Exit-Code ist dann 1. Text selbst wird dabei nicht verändert. Absätze über 10 Sätze werden nochmals geteilt, Absätze unter 3 Sätzen mit dem Nachbarn verbunden. +6. **Änderungsbericht:** Rohtext (vor dem Glossar) und Ergebnis werden Wort für Wort + verglichen, siehe unten. + Zweifelhafte Stellen markiert das Modell als `Lesart [prüfen: Originalwortlaut]`. +## Änderungsbericht + +Jeder Lauf von `rede_korrigieren.py` schreibt neben die Ausgabe +`.aenderungen.html` (aus `rede.korrigiert.txt` wird `rede.aenderungen.html`) +und zeigt die häufigsten Änderungen in der Konsole. Der Bericht enthält: + +- alle Änderungen nach Häufigkeit (`gedeiliche → gedeihliche`, `∅ → ist` für Einfügungen), +- den korrigierten Text mit seinen Absätzen: ersetzte Wörter gelb mit dem Original + dahinter, Einfügungen grün, Streichungen rot durchgestrichen, `[prüfen: …]` orange. + +Nicht markiert werden Groß-/Kleinschreibung, Satzzeichen (auch eingefügte +Gedankenstriche) und Schreibweisen wie „z.B.“ → „z. B.“. So bleiben die Wortänderungen +übrig, die man prüfen sollte, gerade eigenmächtige wie „gedeiliche“ → „geregelte“. + +Für vorhandene Dateien gibt es das eigene Skript: + +```bash +./aenderungen.py -a rede.txt -b rede.korrigiert.txt # → rede.aenderungen.html +./aenderungen.py -a rede.de.vtt -b rede.korrigiert.txt -o bericht.html +``` + ## Glossar Eine Zeile pro Eintrag, `#` leitet Kommentare ein: @@ -202,6 +228,8 @@ Die Tests brauchen kein Ollama: Modellaufrufe werden durch Fakes ersetzt. Geprü werden Abschnittsbildung, Glossar (inkl. „jede Ersetzung im Glossar einer Rede trifft diese Rede, jede im gemeinsamen Glossar trifft mindestens eine“), Streaming-Auswertung, Absatzgliederung (Text bleibt unverändert, 3–10 Sätze), Zwischenspeicher/Fortsetzen und die Kommandozeile. +`tests/test_aenderungen.py` prüft den Wortvergleich (Satzzeichen, Groß-/Kleinschreibung +und „z. B.“ zählen nicht) und den HTML-Bericht (vollständiger Text, Absätze, Escaping). `tests/test_extract_text.py` prüft den Gesamtablauf URL → Text, Sprachwahl (Original-Audio, manuell vor automatisch) und Fehlerfälle. `tests/test_yt_untertitel.py` prüft Spurauswahl, Aufruf von `yt-dlp` und diff --git a/aenderungen.py b/aenderungen.py new file mode 100755 index 0000000..7c16a0f --- /dev/null +++ b/aenderungen.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Stellt Rohabschrift und korrigierten Text gegenüber: welche Wörter hat das Modell +ersetzt, eingefügt oder gestrichen? Ausgabe als HTML-Bericht.""" +from collections import Counter +from dataclasses import dataclass, field +from difflib import SequenceMatcher +from pathlib import Path +import argparse +import html +import re + +from vtt2txt import vtt_to_lines + +REVIEW_MARK = re.compile(r"\[prüfen:[^\]]*\]") +TOKEN = re.compile(r"\[prüfen:[^\]]*\]|\S+") +NON_WORD = re.compile(r"[^\w]+") +ABBREVIATION = re.compile(r"\w\.") # one part of "z. B.", "d. h." +ABBREVIATION_END = re.compile(r"(?:^|\s)\w\.$") +EMPTY = "∅" + + +def add_token(words, token): + """Append a token; attach punctuation-only tokens ("–") to the previous word + and join split abbreviations ("z. B."), so neither counts as a word change.""" + if words and ( + not NON_WORD.sub("", token) + or (ABBREVIATION.fullmatch(token) and ABBREVIATION_END.search(words[-1])) + ): + words[-1] += " " + token + else: + words.append(token) + + +def normalize(word): + """Compare words without case and punctuation; those changes are expected.""" + return NON_WORD.sub("", word.lower()) or word + + +@dataclass +class Comparison: + raw: list # words of the raw transcript + words: list # words of the corrected text, without review marks + paragraph: list # paragraph number of each corrected word + marks: dict = field(default_factory=dict) # word index -> review marks after it + opcodes: list = field(default_factory=list) + + def changes(self): + """Yield (tag, raw words, corrected words) for every difference.""" + for tag, i1, i2, j1, j2 in self.opcodes: + if tag != "equal": + yield tag, self.raw[i1:i2], self.words[j1:j2] + + +def compare(raw, corrected): + """Word-level comparison of the raw transcript and the corrected text.""" + words, paragraph, marks = [], [], {} + for number, text in enumerate(re.split(r"\n\s*\n", corrected.strip())): + start = len(words) + for token in TOKEN.findall(text): + # Review marks stay out of the comparison, but are shown after their word + if REVIEW_MARK.fullmatch(token): + marks.setdefault(len(words) - 1, []).append(token) + # Punctuation is never merged across paragraphs + elif len(words) == start: + words.append(token) + else: + add_token(words, token) + paragraph += [number] * (len(words) - start) + + raw_words = [] + for token in raw.split(): + add_token(raw_words, token) + matcher = SequenceMatcher( + None, [normalize(w) for w in raw_words], [normalize(w) for w in words], + autojunk=False, + ) + return Comparison(raw_words, words, paragraph, marks, matcher.get_opcodes()) + + +def change_summary(comparison): + """[(old, new, count)] of all changes, most frequent first.""" + counts = Counter( + (" ".join(old), " ".join(new)) for _, old, new in comparison.changes() + ) + return [(old, new, count) for (old, new), count in counts.most_common()] + + +STYLE = """ +:root { --bg: #fff; --fg: #1d1d1f; --muted: #6e6e73; --rep: #fff3b0; --ins: #d9f7d9; + --del: #fde2e2; --check: #ffe0b3; --line: #ddd; } +@media (prefers-color-scheme: dark) { + :root { --bg: #1c1c1e; --fg: #f2f2f7; --muted: #a1a1a6; --rep: #5c4d00; --ins: #1f4d1f; + --del: #5c1f1f; --check: #5c3a00; --line: #3a3a3c; } +} +body { background: var(--bg); color: var(--fg); font: 16px/1.6 system-ui, sans-serif; + max-width: 50rem; margin: 0 auto; padding: 1.5rem 1rem; } +h1 { font-size: 1.4rem; } h2 { font-size: 1.15rem; margin-top: 2rem; } +.muted { color: var(--muted); } +table { border-collapse: collapse; width: 100%; font-size: 0.95rem; } +td, th { border-bottom: 1px solid var(--line); padding: 0.2rem 0.5rem; text-align: left; } +td.n { text-align: right; width: 3rem; } +.rep { background: var(--rep); } +ins { background: var(--ins); text-decoration: none; } +del { background: var(--del); } +.orig { color: var(--muted); font-size: 0.8em; } +.check { background: var(--check); font-size: 0.85em; } +""" + + +def render_html(comparison, title): + esc = html.escape + summary = change_summary(comparison) + counts = Counter(tag for tag, _, _ in comparison.changes()) + review_marks = sum(len(m) for m in comparison.marks.values()) + + rows = "\n".join( + f"{count}{esc(old) or EMPTY}" + f"{esc(new) or EMPTY}" + for old, new, count in summary + ) + + # Corrected text with its paragraphs; each word knows its paragraph + parts = [] + current = 0 + + def emit(j, piece): + nonlocal current + if comparison.paragraph[j] != current: + current = comparison.paragraph[j] + parts.append("

\n

") + parts.append(piece) + for mark in comparison.marks.get(j, []): + parts.append(f"{esc(mark)}") + + for tag, i1, i2, j1, j2 in comparison.opcodes: + old = " ".join(comparison.raw[i1:i2]) + if tag == "delete": + parts.append(f"{esc(old)}") + continue + for j in range(j1, j2): + word = esc(comparison.words[j]) + if tag == "equal": + emit(j, word) + elif tag == "insert": + emit(j, f"{word}") + else: + emit(j, f"{word}") + if tag == "replace": + parts.append(f"({esc(old)})") + + text = " ".join(parts).replace("

\n

", "

\n

") + + return f""" + + +Änderungen: {esc(title)} + +

Änderungen: {esc(title)}

+

{len(comparison.raw)} Wörter Rohtext, {len(comparison.words)} Wörter korrigiert. +{counts['replace']} ersetzt, {counts['insert']} eingefügt, {counts['delete']} gestrichen, +{review_marks} Prüfmarken. Groß-/Kleinschreibung und Satzzeichen sind nicht markiert.

+

ersetzt (Original) +eingefügt gestrichen [prüfen: …]

+

Alle Änderungen nach Häufigkeit

+ +{rows} +
×Rohtextkorrigiert
+

Korrigierter Text

+

{text}

+ +""" + + +def print_summary(comparison, limit=15): + summary = change_summary(comparison) + print(f"Änderungen gegenüber dem Rohtext: {sum(c for _, _, c in summary)}") + for old, new, count in summary[:limit]: + print(f" {count}× „{old or EMPTY}“ → „{new or EMPTY}“") + if len(summary) > limit: + print(f" … und {len(summary) - limit} weitere, siehe Bericht") + + +def write_report(raw, corrected, target, title): + comparison = compare(raw, corrected) + target.write_text(render_html(comparison, title), encoding="utf-8") + return comparison + + +def report_path(corrected_path): + """rede.korrigiert.txt -> rede.aenderungen.html""" + stem = corrected_path.stem.removesuffix(".korrigiert") + return corrected_path.with_name(f"{stem}.aenderungen.html") + + +def read_raw(path): + text = path.read_text(encoding="utf-8-sig") + if path.suffix.lower() == ".vtt": + text = "\n".join(vtt_to_lines(text)) + return text + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Stellt Rohabschrift und korrigierten Text als HTML-Bericht gegenüber." + ) + parser.add_argument( + "-a", "--roh", required=True, type=Path, help="Rohabschrift (.txt oder .vtt)" + ) + parser.add_argument( + "-b", "--korrigiert", required=True, type=Path, help="korrigierter Text" + ) + parser.add_argument( + "-o", "--output", type=Path, + help="HTML-Bericht (Standard: .aenderungen.html)", + ) + args = parser.parse_args() + for path in (args.roh, args.korrigiert): + if not path.is_file(): + parser.error(f"Datei nicht gefunden: {path}") + if args.output is None: + args.output = report_path(args.korrigiert) + return args + + +def main(): + args = parse_args() + corrected = args.korrigiert.read_text(encoding="utf-8") + comparison = write_report(read_raw(args.roh), corrected, args.output, args.korrigiert.name) + print_summary(comparison) + print(f"Bericht: {args.output}") + + +if __name__ == "__main__": + main() diff --git a/rede_korrigieren.py b/rede_korrigieren.py index 79e5624..14c536f 100755 --- a/rede_korrigieren.py +++ b/rede_korrigieren.py @@ -9,6 +9,7 @@ import sys import time import urllib.request +from aenderungen import REVIEW_MARK, print_summary, report_path, write_report from vtt2txt import vtt_to_lines MODEL = "qwen3:32b" @@ -22,7 +23,6 @@ MAX_PARAGRAPH_SENTENCES = 10 # längere Absätze werden nachträglich geteilt MIN_PARAGRAPH_SENTENCES = 3 # kürzere Absätze werden mit dem Nachbarn verbunden SENTENCE_END = re.compile(r"[.!?…][\"'»«“”)\]]*$") -REVIEW_MARK = re.compile(r"\[prüfen:[^\]]*\]") SENTENCE_SPLIT = re.compile(r"(?:(?<=[.!?…])|(?<=[.!?…][\"'»«“”)\]]))\s+") SYSTEM = """Du korrigierst eine automatische Abschrift einer deutschen Rede. @@ -378,6 +378,10 @@ def parse_args(): parser.add_argument( "-m", "--model", default=MODEL, help=f"Ollama-Modell (Standard: {MODEL})" ) + parser.add_argument( + "--kein-bericht", action="store_true", + help="keinen Änderungsbericht .aenderungen.html schreiben", + ) args = parser.parse_args() if not args.input.is_file(): @@ -419,6 +423,8 @@ def main(): raw = "\n".join(vtt_to_lines(raw)) # Subtitle line breaks carry no meaning; send plain running text text = " ".join(raw.split()) + # The change report compares against this, so glossary effects show up too + raw_text = text if replacements: text = apply_replacements(text, replacements) @@ -465,6 +471,12 @@ def main(): target.write_text(text + "\n", encoding="utf-8") print(f"Fertig: {target}") + if not args.kein_bericht: + report = report_path(target) + comparison = write_report(raw_text, text, report, target.name) + print_summary(comparison) + print(f"Bericht: {report}") + if warnings: print(f"\n{len(warnings)} Abschnitt(e) mit auffälliger Wortzahl:") for message in warnings: diff --git a/tests/test_aenderungen.py b/tests/test_aenderungen.py new file mode 100644 index 0000000..ba01aaf --- /dev/null +++ b/tests/test_aenderungen.py @@ -0,0 +1,99 @@ +"""Tests for aenderungen.py (change report raw transcript -> corrected text).""" +import html +import re +import sys +from pathlib import Path + +import pytest + +import aenderungen as ae + + +def changes(raw, corrected): + return [(tag, " ".join(old), " ".join(new)) + for tag, old, new in ae.compare(raw, corrected).changes()] + + +def report_text(page): + """Corrected text as shown in the report, without original/deleted words.""" + body = page.split("

Korrigierter Text

")[1] + body = re.sub(r"]*>.*?|.*?", "", body) + return html.unescape(re.sub(r"<[^>]+>", " ", body)).split() + + +@pytest.mark.parametrize("raw, corrected", [ + ("das ist gut", "Das ist gut."), # case, punctuation + ("all das zerstritten", "All das – zerstritten."), # dash only + ("wie z.B. hier", "wie z. B. hier"), # split abbreviation + ("eins zwei", "eins\n\nzwei"), # paragraph break +]) +def test_expected_edits_are_no_changes(raw, corrected): + assert changes(raw, corrected) == [] + + +def test_replace_insert_delete(): + raw = "die gedeiliche Zusammenarbeit war das gut heute" + corrected = "die gedeihliche Zusammenarbeit war doch gut" + assert changes(raw, corrected) == [ + ("replace", "gedeiliche", "gedeihliche"), + ("replace", "das", "doch"), + ("delete", "heute", ""), + ] + assert changes("ein Wort", "ein neues Wort") == [("insert", "", "neues")] + + +def test_change_summary_counts_and_sorts(): + raw = "Kremmel und Kremmel und Bar" + corrected = "Kreml und Kreml und Bahr" + assert ae.change_summary(ae.compare(raw, corrected)) == [ + ("Kremmel", "Kreml", 2), ("Bar", "Bahr", 1), + ] + + +def test_review_marks_are_not_changes_but_shown(): + corrected = "Er heißt Gerhard [prüfen: Ghard] Kocher." + comparison = ae.compare("Er heißt Gerhard Kocher", corrected) + assert list(comparison.changes()) == [] + page = ae.render_html(comparison, "t") + assert "[prüfen: Ghard]" in page + assert "1 Prüfmarken" in page + + +def test_render_html_shows_text_original_and_paragraphs(): + raw = "die gedeiliche Zusammenarbeit ist gut heute neuer Absatz" + corrected = "Die gedeihliche Zusammenarbeit ist gut.\n\nNeuer Absatz." + page = ae.render_html(ae.compare(raw, corrected), "Rede & Co") + + assert report_text(page) == corrected.split() + assert "title='Original: gedeiliche'" in page + assert "heute" in page + assert "<b>" in page and "" not in page.split("")[1] + assert "Rede & Co" in page + assert page.split("

Korrigierter Text

")[1].count("

") == 2 + + +def test_report_path(): + assert ae.report_path(Path("a/rede.korrigiert.txt")) == Path("a/rede.aenderungen.html") + assert ae.report_path(Path("out.txt")) == Path("out.aenderungen.html") + + +def test_cli(tmp_path, monkeypatch, capsys): + raw = tmp_path / "rede.vtt" + raw.write_text( + "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\ndas Kremmel heute\n", encoding="utf-8" + ) + corrected = tmp_path / "rede.korrigiert.txt" + corrected.write_text("Das Kreml heute.\n", encoding="utf-8") + monkeypatch.setattr(sys, "argv", ["aenderungen.py", "-a", str(raw), "-b", str(corrected)]) + ae.main() + + assert (tmp_path / "rede.aenderungen.html").is_file() + out = capsys.readouterr().out + assert "1× „Kremmel“ → „Kreml“" in out + + +def test_cli_missing_file(tmp_path, monkeypatch, capsys): + monkeypatch.setattr(sys, "argv", ["aenderungen.py", "-a", "fehlt.txt", "-b", "fehlt.txt"]) + with pytest.raises(SystemExit): + ae.main() + assert "Datei nicht gefunden" in capsys.readouterr().err diff --git a/tests/test_rede_korrigieren.py b/tests/test_rede_korrigieren.py index 65ed1ee..a9e67ed 100644 --- a/tests/test_rede_korrigieren.py +++ b/tests/test_rede_korrigieren.py @@ -319,6 +319,28 @@ def test_main_vtt_input_matches_txt_input(sample_copy, monkeypatch): ) == expected.read_text(encoding="utf-8") +def test_main_writes_change_report(sample_copy, monkeypatch, capsys): + def correct(chunk, index, system): + return chunk.strip().replace("Rammstein", "Ramstein") + + monkeypatch.setattr(rk, "correct_chunk", correct) + monkeypatch.setattr(rk, "find_paragraph_starts", every_nth_start(5)) + run_main(monkeypatch, "-i", sample_copy) + + report = sample_copy.with_name("ramstein.de.aenderungen.html") + assert "title='Original: Rammstein" in report.read_text(encoding="utf-8") + out = capsys.readouterr().out + assert "„Rammstein“ → „Ramstein“" in out + assert f"Bericht: {report}" in out + + +def test_main_without_change_report(sample_copy, monkeypatch): + monkeypatch.setattr(rk, "correct_chunk", identity_correct) + monkeypatch.setattr(rk, "find_paragraph_starts", every_nth_start(5)) + run_main(monkeypatch, "-i", sample_copy, "--kein-bericht") + assert not sample_copy.with_name("ramstein.de.aenderungen.html").exists() + + def test_main_model_option(sample_copy, monkeypatch): default = rk.MODEL used = []