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:
parent
6610679d80
commit
c1d270bc0e
6 changed files with 397 additions and 1 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,6 +1,7 @@
|
||||||
# Ergebnisse und Zwischenstände
|
# Ergebnisse und Zwischenstände
|
||||||
*.korrigiert.txt
|
*.korrigiert.txt
|
||||||
*.parts/
|
*.parts/
|
||||||
|
*.aenderungen.html
|
||||||
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
|
||||||
28
README.md
28
README.md
|
|
@ -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
234
aenderungen.py
Executable 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()
|
||||||
|
|
@ -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
99
tests/test_aenderungen.py
Normal 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 "<b>" in page and "<b>" not in page.split("<body>")[1]
|
||||||
|
assert "Rede & 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
|
||||||
|
|
@ -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 = []
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue