From 37e4a2bca0aaa1c1d285da2b7de2373b38ca448b Mon Sep 17 00:00:00 2001 From: dschlueter Date: Sun, 11 Oct 2026 21:57:12 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20von=20Hand=20nachbearbeitete=20Ausgabe?= =?UTF-8?q?=20wird=20nicht=20mehr=20=C3=BCberschrieben?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit abschrift_korrigieren.py merkt sich die Prüfsumme der geschriebenen Ausgabe in .parts/ausgabe.sha256. Weicht die vorhandene Datei davon und vom neuen Ergebnis ab, bleibt sie erhalten; das Ergebnis kommt nach .neu.korrigiert.txt (mit eigenem Bericht) und es gibt eine Warnung. --ueberschreiben ersetzt die Datei trotzdem. Co-Authored-By: Claude Opus 5.5 --- BEDIENUNGSANLEITUNG.md | 23 ++++++++-- README.md | 6 +++ abschrift_korrigieren.py | 48 +++++++++++++++++++- tests/test_abschrift_korrigieren.py | 70 +++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 5 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 8da24e7..d7dc224 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -524,9 +524,22 @@ neu, was sich geändert hat: - Die Absatzgliederung wird für geänderte Textstellen ebenfalls neu bestimmt; das geht schnell. -Von Hand nachbearbeitete `*.korrigiert.txt` werden bei einem erneuten Lauf -**überschrieben**. Handarbeit deshalb erst ganz am Ende machen oder die Datei vorher -unter anderem Namen sichern. +**Handarbeit bleibt erhalten.** Wurde `rede.korrigiert.txt` seit dem letzten Lauf +von Hand geändert, überschreibt ein erneuter Lauf sie nicht. Das neue Ergebnis kommt +dann nach `rede.neu.korrigiert.txt` (Bericht: `rede.neu.aenderungen.html`), mit +einer Warnung: + +```text +WARNUNG: rede.korrigiert.txt wurde seit dem letzten Lauf geändert (von Hand?) und bleibt erhalten. Neues Ergebnis: rede.neu.korrigiert.txt (mit --ueberschreiben wird die Datei ersetzt) +``` + +Die eigenen Änderungen lassen sich dann mit einem Vergleichsprogramm übertragen, +z. B. `meld rede.korrigiert.txt rede.neu.korrigiert.txt`. Soll die Handarbeit +verworfen werden: `--ueberschreiben` angeben. + +Das Programm erkennt Handarbeit an einer Prüfsumme in `rede.korrigiert.parts/`. +Fehlt sie (erster Lauf mit dieser Version, Ordner gelöscht), gilt jede abweichende +vorhandene Datei als von Hand geändert. --- @@ -618,7 +631,7 @@ Beispiel für `-i rede.txt` bzw. `-o rede.korrigiert.txt`: | Datei | Inhalt | Wird überschrieben? | |---|---|---| | `rede.txt` | Rohtext (bei `-u` automatisch gespeichert) | nie, wenn sie schon mit anderem Inhalt existiert (dann Warnung) | -| `rede.korrigiert.txt` | korrigierte Abschrift | ja, bei jedem Lauf | +| `rede.korrigiert.txt` | korrigierte Abschrift | ja, außer sie wurde von Hand geändert (dann `rede.neu.korrigiert.txt`, siehe [Abschnitt 9](#9-schritt-5-nachbessern-und-erneut-laufen-lassen)) | | `rede.aenderungen.html` | Änderungsbericht | ja, bei jedem Lauf | | `rede.korrigiert.parts/` | Zwischenstände | wird ergänzt; darf jederzeit gelöscht werden | | `rede.glossar.entwurf.txt` | Glossar-Entwurf von `glossar_vorschlag.py` | ja | @@ -693,6 +706,7 @@ Weitere Einstellungen (Abschnittsgröße, Absatzlängen, Schwellen) sind in der | `Die Eingabedatei ist leer.` | Datei prüfen. | | `Ausgabedatei darf nicht die Eingabedatei sein.` | Mit `-o` einen anderen Namen wählen. | | `Glossar nicht gefunden: …` | Pfad bei `-g` prüfen. | +| `WARNUNG: rede.korrigiert.txt wurde seit dem letzten Lauf geändert (von Hand?) …` | Die Datei wurde nachbearbeitet und bleibt erhalten; das neue Ergebnis steht in `rede.neu.korrigiert.txt`. Änderungen übertragen oder mit `--ueberschreiben` ersetzen, siehe [Abschnitt 9](#9-schritt-5-nachbessern-und-erneut-laufen-lassen). | | `WARNUNG: rede.txt existiert mit anderem Inhalt und bleibt unverändert.` | Bei `-u` wird ein vorhandener, z. B. von Hand bearbeiteter Rohtext nie überschrieben. Korrigiert wird trotzdem der frisch geladene Text. Wer den neuen Rohtext braucht, die alte Datei umbenennen. | | `WARNUNG: Abschnitt 3: 1180 → 1090 Wörter (8% Abweichung) …` | Das Modell hat möglicherweise etwas ausgelassen oder ergänzt. Abschnitt im Bericht prüfen. Bei Bedarf die genannte Datei in `.parts/` löschen und erneut laufen lassen. | | `Abschnitt 3 wurde abgeschnitten. CHUNK_SIZE verkleinern und erneut starten.` | Die Antwort des Modells war zu lang. `CHUNK_SIZE` oben in `abschrift_korrigieren.py` verkleinern (z. B. 900). | @@ -763,6 +777,7 @@ einfügen. | `-z BEREICH …` | nur diese Zeitbereiche (mit `.vtt` oder `-u`) | | `--exakt` | genau an den Zeiten schneiden | | `--kein-bericht` | keinen Änderungsbericht schreiben | +| `--ueberschreiben` | von Hand geänderte Ausgabedatei trotzdem ersetzen | ### `extract_text.py` diff --git a/README.md b/README.md index 1e5bebd..7a1e834 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,12 @@ werden trotzdem umgewandelt; der Exit-Code ist dann 1. | `-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 | +| `--ueberschreiben` | Ausgabedatei auch ersetzen, wenn sie seit dem letzten Lauf von Hand geändert wurde | + +Eine von Hand nachbearbeitete Ausgabedatei überschreibt ein erneuter Lauf nicht: Das +Ergebnis kommt dann nach `.neu.korrigiert.txt`, mit Warnung. Erkannt wird das +an der Prüfsumme der zuletzt geschriebenen Ausgabe in `.parts/ausgabe.sha256`; +ohne Prüfsumme gilt jede abweichende vorhandene Datei als von Hand geändert. Mit Zeitauswahl wird jeder Bereich als eigener Block korrigiert und gegliedert; die Überschriften `[00:04:57 - 00:06:04]` bekommt das Modell nicht zu sehen, sie diff --git a/abschrift_korrigieren.py b/abschrift_korrigieren.py index 6be2380..d5416ad 100755 --- a/abschrift_korrigieren.py +++ b/abschrift_korrigieren.py @@ -33,6 +33,7 @@ MAX_WORD_DEVIATION = 0.05 # ab dieser Abweichung der Wortzahl wird gewarnt PARAGRAPH_WINDOW = 150 # Sätze pro Anfrage bei der Absatzgliederung 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 +OUTPUT_RECORD = "ausgabe.sha256" # in .parts/: Prüfsumme der zuletzt geschriebenen Ausgabe SENTENCE_END = re.compile(r"[.!?…][\"'»«“”)\]]*$") SENTENCE_SPLIT = re.compile(r"(?:(?<=[.!?…])|(?<=[.!?…][\"'»«“”)\]]))\s+") @@ -641,6 +642,11 @@ def parse_args(): "--kein-bericht", action="store_true", help="keinen Änderungsbericht .aenderungen.html schreiben", ) + parser.add_argument( + "--ueberschreiben", action="store_true", + help="die Ausgabedatei auch dann überschreiben, wenn sie seit dem letzten Lauf " + "von Hand geändert wurde (sonst entsteht .neu.korrigiert.txt)", + ) add_time_options(parser) args = parser.parse_args(range_argv(sys.argv[1:])) resolve_time_ranges(parser, args) @@ -708,6 +714,35 @@ def read_source(args): return raw, args.output, base_language(args.lang or DEFAULT_LANGUAGE) +def text_hash(text): + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def new_output_path(target): + """rede.korrigiert.txt -> rede.neu.korrigiert.txt, out.txt -> out.neu.txt""" + if target.name.endswith(".korrigiert.txt"): + stem = target.name.removesuffix(".korrigiert.txt") + return target.with_name(f"{stem}.neu.korrigiert.txt") + return target.with_name(f"{target.stem}.neu{target.suffix}") + + +def choose_output(target, content, parts_dir, overwrite=False): + """Where to write `content`: the target, unless it was changed by hand. + + A target counts as changed by hand if it differs both from what the last + run wrote there (checksum in parts_dir) and from the new content. Without + a checksum (first run, .parts deleted) any differing target counts. + """ + if overwrite or not target.exists(): + return target + current = target.read_text(encoding="utf-8") + record = parts_dir / OUTPUT_RECORD + last = record.read_text(encoding="utf-8").strip() if record.exists() else None + if current == content or text_hash(current) == last: + return target + return new_output_path(target) + + def main(): global MODEL args = parse_args() @@ -800,7 +835,18 @@ def main(): corrected_blocks.append((header, paragraphs)) text = join_segments(corrected_blocks, header_gap="\n\n") - target.write_text(text + "\n", encoding="utf-8") + content = text + "\n" + output = choose_output(target, content, parts_dir, args.ueberschreiben) + output.write_text(content, encoding="utf-8") + if output == target: + (parts_dir / OUTPUT_RECORD).write_text(text_hash(content) + "\n", encoding="utf-8") + else: + print( + f"WARNUNG: {target} wurde seit dem letzten Lauf geändert (von Hand?) und " + f"bleibt erhalten. Neues Ergebnis: {output} " + "(mit --ueberschreiben wird die Datei ersetzt)" + ) + target = output print(f"Fertig: {target}") if not args.kein_bericht: diff --git a/tests/test_abschrift_korrigieren.py b/tests/test_abschrift_korrigieren.py index ec5788b..287c980 100644 --- a/tests/test_abschrift_korrigieren.py +++ b/tests/test_abschrift_korrigieren.py @@ -414,6 +414,76 @@ def test_main_default_output_resume_and_glossary(sample_copy, monkeypatch, capsy assert target.read_text(encoding="utf-8") == result +def shouting(chunk, index, system): + """A different model result than identity_correct.""" + return chunk.strip().replace("Vielen Dank", "VIELEN DANK") + + +def test_main_overwrites_own_output(sample_copy, monkeypatch, capsys): + monkeypatch.setattr(ak, "correct_chunk", identity_correct) + monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5)) + run_main(monkeypatch, "-i", sample_copy) + target = sample_copy.with_name("ramstein.de.korrigiert.txt") + + # Unchanged output of the last run is replaced by a different result + monkeypatch.setattr(ak, "correct_chunk", shouting) + run_main(monkeypatch, "-i", sample_copy, "-m", "anderes:modell") + assert "VIELEN DANK" in target.read_text(encoding="utf-8") + assert not ak.new_output_path(target).exists() + assert "WARNUNG" not in capsys.readouterr().out + + +def test_main_keeps_hand_edited_output(sample_copy, monkeypatch, capsys): + monkeypatch.setattr(ak, "correct_chunk", identity_correct) + monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5)) + run_main(monkeypatch, "-i", sample_copy) + target = sample_copy.with_name("ramstein.de.korrigiert.txt") + edited = target.read_text(encoding="utf-8") + "Von Hand ergänzt.\n" + target.write_text(edited, encoding="utf-8") + capsys.readouterr() + + monkeypatch.setattr(ak, "correct_chunk", shouting) + run_main(monkeypatch, "-i", sample_copy, "-m", "anderes:modell") + + new = sample_copy.with_name("ramstein.de.neu.korrigiert.txt") + assert target.read_text(encoding="utf-8") == edited + assert "VIELEN DANK" in new.read_text(encoding="utf-8") + assert sample_copy.with_name("ramstein.de.neu.aenderungen.html").is_file() + out = capsys.readouterr().out + assert f"WARNUNG: {target} wurde seit dem letzten Lauf geändert" in out + assert f"Fertig: {new}" in out + + # --ueberschreiben replaces the edited file + run_main(monkeypatch, "-i", sample_copy, "-m", "anderes:modell", "--ueberschreiben") + assert "VIELEN DANK" in target.read_text(encoding="utf-8") + + +def test_main_keeps_differing_output_without_checksum(sample_copy, monkeypatch, capsys): + target = sample_copy.with_name("ramstein.de.korrigiert.txt") + target.write_text("Älteres Ergebnis, Herkunft unbekannt.\n", encoding="utf-8") + monkeypatch.setattr(ak, "correct_chunk", identity_correct) + monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5)) + run_main(monkeypatch, "-i", sample_copy) + + assert target.read_text(encoding="utf-8") == "Älteres Ergebnis, Herkunft unbekannt.\n" + assert sample_copy.with_name("ramstein.de.neu.korrigiert.txt").is_file() + assert "WARNUNG" in capsys.readouterr().out + + +@pytest.mark.parametrize("name, expected", [ + ("rede.korrigiert.txt", "rede.neu.korrigiert.txt"), + ("out.txt", "out.neu.txt"), +]) +def test_new_output_path(name, expected): + assert ak.new_output_path(Path("a") / name) == Path("a") / expected + + +def test_choose_output_same_content_is_no_conflict(tmp_path): + target = tmp_path / "rede.korrigiert.txt" + target.write_text("gleich\n", encoding="utf-8") + assert ak.choose_output(target, "gleich\n", tmp_path / "fehlt.parts") == target + + def test_main_output_option_creates_directories(sample_copy, monkeypatch): monkeypatch.setattr(ak, "correct_chunk", identity_correct) monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))