feat: Änderungsbericht Rohtext → Korrektur (aenderungen.py)

- 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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-10 19:59:37 +02:00
commit c1d270bc0e
6 changed files with 397 additions and 1 deletions

1
.gitignore vendored
View file

@ -1,6 +1,7 @@
# Ergebnisse und Zwischenstände # Ergebnisse und Zwischenstände
*.korrigiert.txt *.korrigiert.txt
*.parts/ *.parts/
*.aenderungen.html
__pycache__/ __pycache__/
*.pyc *.pyc

View file

@ -12,6 +12,7 @@ Die Werkzeuge sind einzeln oder hintereinander nutzbar:
| `yt_untertitel.py` | Untertitel eines YouTube-Videos als VTT herunterladen | | `yt_untertitel.py` | Untertitel eines YouTube-Videos als VTT herunterladen |
| `vtt2txt.py` | VTT in eine Rohabschrift (Text) umwandeln | | `vtt2txt.py` | VTT in eine Rohabschrift (Text) umwandeln |
| `rede_korrigieren.py` | Rohabschrift (oder VTT direkt) mit dem Sprachmodell korrigieren | | `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 ## Voraussetzungen
@ -132,6 +133,7 @@ werden trotzdem umgewandelt; der Exit-Code ist dann 1.
| `-o`, `--output` | Ausgabedatei; Standard: `<Eingabe ohne Endung>.korrigiert.txt` neben der Eingabe | | `-o`, `--output` | Ausgabedatei; Standard: `<Eingabe ohne Endung>.korrigiert.txt` neben der Eingabe |
| `-g`, `--glossar` | Glossar mit festen Ersetzungen und Schreibweisen; mehrfach angeben zum Kombinieren (Ersetzungen in Reihenfolge der Angabe) | | `-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`) | | `-m`, `--model` | Ollama-Modell; Standard: `qwen3:32b` (Konstante `MODEL`) |
| `--kein-bericht` | keinen Änderungsbericht schreiben |
## Ablauf ## 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 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. 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]`. Zweifelhafte Stellen markiert das Modell als `Lesart [prüfen: Originalwortlaut]`.
## Änderungsbericht
Jeder Lauf von `rede_korrigieren.py` schreibt neben die Ausgabe
`<Name>.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 ## Glossar
Eine Zeile pro Eintrag, `#` leitet Kommentare ein: 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 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 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. 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 `tests/test_extract_text.py` prüft den Gesamtablauf URL → Text, Sprachwahl
(Original-Audio, manuell vor automatisch) und Fehlerfälle. (Original-Audio, manuell vor automatisch) und Fehlerfälle.
`tests/test_yt_untertitel.py` prüft Spurauswahl, Aufruf von `yt-dlp` und `tests/test_yt_untertitel.py` prüft Spurauswahl, Aufruf von `yt-dlp` und

234
aenderungen.py Executable file
View file

@ -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"<tr><td class=n>{count}</td><td><del>{esc(old) or EMPTY}</del></td>"
f"<td><ins>{esc(new) or EMPTY}</ins></td></tr>"
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("</p>\n<p>")
parts.append(piece)
for mark in comparison.marks.get(j, []):
parts.append(f"<span class=check>{esc(mark)}</span>")
for tag, i1, i2, j1, j2 in comparison.opcodes:
old = " ".join(comparison.raw[i1:i2])
if tag == "delete":
parts.append(f"<del title='gestrichen'>{esc(old)}</del>")
continue
for j in range(j1, j2):
word = esc(comparison.words[j])
if tag == "equal":
emit(j, word)
elif tag == "insert":
emit(j, f"<ins title='eingefügt'>{word}</ins>")
else:
emit(j, f"<span class=rep title='Original: {esc(old, quote=True)}'>{word}</span>")
if tag == "replace":
parts.append(f"<span class=orig>({esc(old)})</span>")
text = " ".join(parts).replace(" </p>\n<p> ", "</p>\n<p>")
return f"""<!doctype html>
<html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Änderungen: {esc(title)}</title><style>{STYLE}</style></head>
<body>
<h1>Änderungen: {esc(title)}</h1>
<p class=muted>{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.</p>
<p><span class=rep>ersetzt</span> <span class=orig>(Original)</span>
<ins>eingefügt</ins> <del>gestrichen</del> <span class=check>[prüfen: …]</span></p>
<h2>Alle Änderungen nach Häufigkeit</h2>
<table><tr><th class=n>×</th><th>Rohtext</th><th>korrigiert</th></tr>
{rows}
</table>
<h2>Korrigierter Text</h2>
<p>{text}</p>
</body></html>
"""
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: <korrigiert ohne .korrigiert.txt>.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()

View file

@ -9,6 +9,7 @@ import sys
import time import time
import urllib.request import urllib.request
from aenderungen import REVIEW_MARK, print_summary, report_path, write_report
from vtt2txt import vtt_to_lines from vtt2txt import vtt_to_lines
MODEL = "qwen3:32b" 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 MIN_PARAGRAPH_SENTENCES = 3 # kürzere Absätze werden mit dem Nachbarn verbunden
SENTENCE_END = re.compile(r"[.!?…][\"'»«“”)\]]*$") SENTENCE_END = re.compile(r"[.!?…][\"'»«“”)\]]*$")
REVIEW_MARK = re.compile(r"\[prüfen:[^\]]*\]")
SENTENCE_SPLIT = re.compile(r"(?:(?<=[.!?…])|(?<=[.!?…][\"'»«“”)\]]))\s+") SENTENCE_SPLIT = re.compile(r"(?:(?<=[.!?…])|(?<=[.!?…][\"'»«“”)\]]))\s+")
SYSTEM = """Du korrigierst eine automatische Abschrift einer deutschen Rede. SYSTEM = """Du korrigierst eine automatische Abschrift einer deutschen Rede.
@ -378,6 +378,10 @@ def parse_args():
parser.add_argument( parser.add_argument(
"-m", "--model", default=MODEL, help=f"Ollama-Modell (Standard: {MODEL})" "-m", "--model", default=MODEL, help=f"Ollama-Modell (Standard: {MODEL})"
) )
parser.add_argument(
"--kein-bericht", action="store_true",
help="keinen Änderungsbericht <Ausgabe>.aenderungen.html schreiben",
)
args = parser.parse_args() args = parser.parse_args()
if not args.input.is_file(): if not args.input.is_file():
@ -419,6 +423,8 @@ def main():
raw = "\n".join(vtt_to_lines(raw)) raw = "\n".join(vtt_to_lines(raw))
# Subtitle line breaks carry no meaning; send plain running text # Subtitle line breaks carry no meaning; send plain running text
text = " ".join(raw.split()) text = " ".join(raw.split())
# The change report compares against this, so glossary effects show up too
raw_text = text
if replacements: if replacements:
text = apply_replacements(text, replacements) text = apply_replacements(text, replacements)
@ -465,6 +471,12 @@ def main():
target.write_text(text + "\n", encoding="utf-8") target.write_text(text + "\n", encoding="utf-8")
print(f"Fertig: {target}") 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: if warnings:
print(f"\n{len(warnings)} Abschnitt(e) mit auffälliger Wortzahl:") print(f"\n{len(warnings)} Abschnitt(e) mit auffälliger Wortzahl:")
for message in warnings: for message in warnings:

99
tests/test_aenderungen.py Normal file
View file

@ -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("<h2>Korrigierter Text</h2>")[1]
body = re.sub(r"<del[^>]*>.*?</del>|<span class=orig>.*?</span>", "", 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 "<span class=check>[prüfen: Ghard]</span>" in page
assert "1 Prüfmarken" in page
def test_render_html_shows_text_original_and_paragraphs():
raw = "die gedeiliche Zusammenarbeit <b> ist gut heute neuer Absatz"
corrected = "Die gedeihliche Zusammenarbeit <b> 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 "<del title='gestrichen'>heute</del>" in page
assert "&lt;b&gt;" in page and "<b>" not in page.split("<body>")[1]
assert "Rede &amp; Co" in page
assert page.split("<h2>Korrigierter Text</h2>")[1].count("<p>") == 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

View file

@ -319,6 +319,28 @@ def test_main_vtt_input_matches_txt_input(sample_copy, monkeypatch):
) == expected.read_text(encoding="utf-8") ) == 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): def test_main_model_option(sample_copy, monkeypatch):
default = rk.MODEL default = rk.MODEL
used = [] used = []