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
+| × | Rohtext | korrigiert |
+{rows}
+
+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 = []