Files
swp-02-aufbereitung/test_revisionen.py
T
irrlichtandClaude Opus 5 cf89e012b2 Revisionsmodul, Embeddings und Container
Drei Schritte, die zusammen Phase 2 lauffaehig machen.

Revisionen (migrations/p2-004, revisionen.py)
    Ingest schreibt jede geaenderte Fassung eines Items nach
    raw_item_versionen fort. Daraus faellt ein Befund ab, den die
    Spezifikation nicht vorsehen konnte: welches Haus seine Schlagzeile
    nachtraeglich umschreibt, und wie. 206 von 3407 Items, acht
    Einstufungen von 'formal' bis 'neufassung'. Kein Modell, kein Lexikon,
    kein Netzzugriff - nur ein Wortvergleich zweier Fassungen.

    Fortschreibungen sind ueber ticker_markiert und kopf_stabil abziehbar,
    absichtlich als zwei Merkmale statt einer Kategorie: das eine ist eine
    Selbstauskunft des Hauses, das andere eine Beobachtung am Satzbau.

Embeddings (embeddings.py)
    multilingual-e5-large, 1024 Dimensionen, MIT. Jede Zeile wird auf feste
    128 Token aufgefuellt, nicht nur bis zur laengsten des Stapels - sonst
    haengt ein Vektor davon ab, mit welchen Nachbarn er kodiert wurde, und
    ein Slot-Lauf liefert andere letzte Stellen als ein Backfill.
    Nachgewiesen: 64/64 bitgleich einzeln wie im vollen Stapel, groesste
    Abweichung 0.00e+00, und im Betrieb 26 Slot-Vektoren unveraendert
    gegen den 3407er-Backfill.

    Darum transformers statt sentence-transformers: letzteres kapselt genau
    diese Stelle weg.

Container (Containerfile, phase2.py, deploy/)
    Debian trixie wie der Postgres-Container, torch aus dem CPU-Index.
    Die regelbasierten Module liefern darin fuer alle 3407 Items bitgleich
    dieselbe Nutzlast wie auf der Entwicklungsmaschine - Portabilitaet
    gemessen, nicht behauptet.

version.py bekommt den Schluessel 'revision'. Weil der Hash ueber den ganzen
Komponentensatz laeuft, aendert das auch die Version des Dublettenmoduls,
obwohl an dessen Verfahren nichts anders ist. Das ist die in version.py
beschriebene Semantik der Spezifikation, kein Versehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NupWEBPzsyohVPG47HsTz8
2026-09-08 07:41:27 +02:00

289 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Testfaelle fuer die Revisionserkennung.
python3 -m unittest test_revisionen -v
Ohne Datenbank. Die Beispiele stammen saemtlich aus dem Bestand vom
4.-7.9.2026; erfundene Faelle pruefen hier nichts, weil das Modul eine
Behauptung ueber echte Redaktionssprache aufstellt.
"""
import datetime as dt
import unittest
import normalisierung as n
import revisionen as r
def fassung(item, minute, titel, teaser="", url="u"):
return r.Fassung(item, "zeit",
dt.datetime(2026, 9, 7, 8, 0) + dt.timedelta(minutes=minute),
dt.datetime(2026, 9, 7, 8, 0), titel, teaser, url)
class Pluskasten(unittest.TestCase):
"""Fuer Revisionen muss der +++-Kasten stehen bleiben."""
TICKER = "++ Liveticker zur Sachsen-Anhalt-Wahl: Wahllokale haben geoeffnet ++"
def test_dublettenweg_leert_den_titel(self):
self.assertEqual(n.normalisiere(self.TICKER), "")
def test_revisionsweg_behaelt_ihn(self):
self.assertIn("wahllokale", n.normalisiere(self.TICKER, pluskasten=False))
class Ressortteilung(unittest.TestCase):
def test_kopf_und_rest(self):
kopf, rest = n.ressort_teilen(
"Landtagswahl: AfD-Triumph in Sachsen-Anhalt erschwert die Regierungsbildung")
self.assertEqual(kopf, "Landtagswahl")
self.assertEqual(rest, "AfD-Triumph in Sachsen-Anhalt erschwert die Regierungsbildung")
def test_kein_kopf_wenn_der_rest_zu_kurz_ist(self):
# Die Vorsicht aus dubletten.py: bliebe hinter dem Doppelpunkt kein
# Satz stehen, war es keine Ressortmarke.
self.assertEqual(n.ressort_teilen("Landtagswahl: AfD-Triumph")[0], None)
def test_kein_kopf_bei_zitat(self):
# "Habeck: Wir haben uns geirrt" - der Teil vor dem Doppelpunkt
# traegt die Aussage. ressort_teilen darf ihn nicht abschneiden.
self.assertEqual(n.ressort_teilen("Habeck: Wir haben uns geirrt")[0], None)
def test_abtrennen_nutzt_teilung(self):
t = "Fussball: FIFA-Praesident sagt Besuch in Berlin ab"
self.assertEqual(n._ressort_abtrennen(t), n.ressort_teilen(t)[1])
class Klassifikation(unittest.TestCase):
def urteil(self, alt, neu):
return r.klassifiziere(alt, neu)
def test_unveraendert(self):
self.assertEqual(self.urteil("Gleicher Titel", "Gleicher Titel"), "unveraendert")
def test_formal_gedankenstrich(self):
self.assertEqual(self.urteil(
"Berliner Senat zahlt nicht - sensible Daten jetzt im Darknet",
"Berliner Senat zahlt nicht – sensible Daten jetzt im Darknet"), "formal")
def test_formal_apostroph(self):
self.assertEqual(self.urteil("Zeigen, wie’s geht in der Bergdiele",
"Zeigen, wie's geht in der Bergdiele"), "formal")
def test_tippfehler(self):
self.assertEqual(self.urteil(
"Waldesaster in Sachsen-Anhalt: Warken sieht Reformkurs bestaetigt",
"Wahldesaster in Sachsen-Anhalt: Warken sieht Reformkurs bestaetigt"),
"tippfehler")
def test_tippfehler_nicht_bei_wortkuerzung(self):
# "ARD-Sondersendung" -> "ARD-Sendung" ist eine Kuerzung, kein
# Tippfehler. Die Laengengrenze muss das trennen.
self.assertNotEqual(self.urteil(
"Livestream: ARD-Sondersendung zur Wahl in Sachsen-Anhalt",
"Livestream: ARD-Sendung zur Wahl in Sachsen-Anhalt"), "tippfehler")
def test_kopfwechsel(self):
self.assertEqual(self.urteil(
"Sabotage an Umspannwerken: Polizei fahndet nach mutmasslichem Taeter",
"Stromnetz-Sabotage: Polizei fahndet nach mutmasslichem Taeter"),
"kopfwechsel")
def test_kopf_entfernt_ist_kopfwechsel(self):
self.assertEqual(self.urteil(
"Fussball-Bundesliga: Bayer Leverkusen zerlegt zahnloses Union Berlin",
"Bayer Leverkusen zerlegt zahnloses Union Berlin"), "kopfwechsel")
def test_erweiterung(self):
self.assertEqual(self.urteil(
"Natalie Portman ist zum dritten Mal Mutter geworden",
"Natalie Portman ist mit 45 zum dritten Mal Mutter geworden"),
"erweiterung")
def test_kuerzung(self):
self.assertEqual(self.urteil(
"Top 10: Maehroboter ohne Begrenzungskabel im Test - Dreame vor Mammotion",
"Top 10: Maehroboter ohne Begrenzungskabel"), "kuerzung")
def test_umformulierung(self):
self.assertEqual(self.urteil(
"Landtagswahl: AfD-Triumph in Sachsen-Anhalt - Schwierige Regierungsbildung",
"Landtagswahl: AfD-Triumph in Sachsen-Anhalt - Regierungsbildung schwierig"),
"umformulierung")
def test_neufassung(self):
self.assertEqual(self.urteil(
"Explosionen nahe iranischer Oelinsel Kharg gemeldet",
"Iranische Revolutionsgarden haben drei Oeltanker in der Strasse"
" von Hormus angegriffen"), "neufassung")
def test_leere_fassung_bleibt_unbestimmt(self):
self.assertEqual(self.urteil("...", "Ein richtiger Titel mit Woertern"),
"unbestimmt")
def test_richtung_zaehlt(self):
a = "Polizei fahndet nach Taeter"
b = "Polizei fahndet mit Fotos nach Taeter"
self.assertEqual(self.urteil(a, b), "erweiterung")
self.assertEqual(self.urteil(b, a), "kuerzung")
class Wortdiff(unittest.TestCase):
def test_hinzu_und_entfernt(self):
hinzu, entfernt = r.wortdiff("Polizei fahndet nach Taeter",
"Polizei sucht mit Fotos nach Taeter")
self.assertEqual(hinzu, ["sucht", "mit", "fotos"])
self.assertEqual(entfernt, ["fahndet"])
def test_aehnlichkeit_gleich_ist_eins(self):
self.assertEqual(r.aehnlichkeit("Ein Titel", "Ein Titel"), 1.0)
def test_aehnlichkeit_faellt_bei_austausch(self):
self.assertLess(
r.aehnlichkeit("Explosionen nahe iranischer Oelinsel gemeldet",
"Revolutionsgarden greifen Oeltanker in Hormus an"),
r.aehnlichkeit("Explosionen nahe iranischer Oelinsel gemeldet",
"Explosionen nahe iranischer Oelinsel bestaetigt"))
class Tickermarken(unittest.TestCase):
def test_erkannte_marken(self):
for t in ("++ Liveticker zur Wahl: Stand ++",
"Newsblog zum Ukraine-Krieg: Angriffe dauern an",
"Liveblog zur Wahl in Sachsen-Anhalt: Klingbeil aeussert sich",
"Livestream: ARD-Sondersendung zur Wahl",
"Live: Heute im Livestream: Sachsen-Anhalt hat gewaehlt"):
self.assertTrue(r.ticker_markiert(t), t)
def test_liverpool_ist_kein_ticker(self):
self.assertFalse(r.ticker_markiert(
"Liverpool gelingt bei Ipswich der erste Saisonsieg"))
def test_ressortkasten_ist_kein_ticker(self):
# handelsblatt setzt "+++ USA +++:" als Ressortmarke.
self.assertFalse(r.ticker_markiert(
"+++ USA +++: Briefwahl in den USA beginnt"))
class Rubrik(unittest.TestCase):
"""kopf() sieht nur nach, ob die Rubrik dieselbe blieb - ohne die
Vorsicht von ressort_teilen(), die kurze Tickerstaende verschluckt."""
def test_kurzer_stand_behaelt_seinen_kopf(self):
self.assertEqual(r.kopf("Liveblog zur Wahl: Wahllokale sind geoeffnet"),
r.kopf("Liveblog zur Wahl: Erste Hochrechnung sieht AfD vorn"))
def test_ohne_doppelpunkt_kein_kopf(self):
self.assertIsNone(r.kopf("Hertha und Magdeburg liefern sich ein wildes Spiel"))
def test_kopf_ohne_wortgrenze(self):
# ressort_teilen() liesse den fuenfwortigen Kopf fallen, teilen() nicht.
a = "CDU-Desaster in Sachsen-Anhalt: Jetzt wird es richtig chaotisch"
b = "CDU-Desaster bei Wahl in Sachsen-Anhalt: Jetzt wird es richtig chaotisch"
self.assertEqual(r.klassifiziere(a, b), "kopfwechsel")
def test_rest_wird_mitgeteilt(self):
self.assertEqual(r.teilen("Verkehr: Unfall auf der A3"),
("verkehr", "unfall auf der a3"))
def test_gewechselte_rubrik(self):
self.assertNotEqual(r.kopf("Sabotage an Umspannwerken: Polizei fahndet"),
r.kopf("Stromnetz-Sabotage: Polizei fahndet"))
class Zahlen(unittest.TestCase):
def test_geaenderte_zahl(self):
self.assertNotEqual(r.zahlen("54,4 Prozent am Nachmittag"),
r.zahlen("65,3 Prozent am Nachmittag"))
def test_umgestellte_zahl_bleibt_gleich(self):
self.assertEqual(r.zahlen("2 zu 1 fuer Hertha"), r.zahlen("1 zu 2 fuer Hertha"))
class Nutzlast(unittest.TestCase):
def test_unveraendertes_item(self):
l = r.nutzlast([fassung(1, 0, "Ein Titel, der so bleibt")])
self.assertEqual(l["fassungen"], 1)
self.assertEqual(l["titel_aenderungen"], 0)
self.assertEqual(l["art"], "unveraendert")
self.assertEqual(l["hinzu"], [])
self.assertEqual(l["aehnlichkeit"], 1.0)
def test_erste_gegen_letzte(self):
l = r.nutzlast([
fassung(2, 0, "Polizei fahndet nach Taeter"),
fassung(2, 15, "Polizei fahndet mit Fotos nach Taeter"),
fassung(2, 45, "Polizei fahndet mit Fotos und Video nach Taeter"),
])
self.assertEqual(l["fassungen"], 3)
self.assertEqual(l["titel_aenderungen"], 2)
self.assertEqual(l["arten"], ["erweiterung", "erweiterung"])
self.assertEqual(l["art"], "erweiterung")
self.assertEqual(l["spanne_min"], 45)
def test_zurueckgenommen(self):
# Der faz-Fall aus dem Bestand: "dessen" eingefuegt und wieder
# gestrichen. Erste und letzte Fassung sind gleich, geaendert wurde
# trotzdem.
l = r.nutzlast([
fassung(3, 0, "Siegmund dankt Elon Musk fuer Unterstuetzung"),
fassung(3, 90, "Siegmund dankt Elon Musk fuer dessen Unterstuetzung"),
fassung(3, 120, "Siegmund dankt Elon Musk fuer Unterstuetzung"),
])
self.assertEqual(l["art"], "zurueckgenommen")
self.assertEqual(l["titel_aenderungen"], 2)
def test_kopf_stabil(self):
stabil = r.nutzlast([
fassung(4, 0, "Liveblog zur Wahl: Wahllokale sind geoeffnet"),
fassung(4, 60, "Liveblog zur Wahl: Erste Hochrechnung sieht AfD vorn"),
])
self.assertTrue(stabil["kopf_stabil"])
self.assertTrue(stabil["ticker_markiert"])
gewechselt = r.nutzlast([
fassung(5, 0, "Sabotage an Umspannwerken: Polizei fahndet nach Taeter"),
fassung(5, 60, "Stromnetz-Sabotage: Polizei fahndet nach Taeter"),
])
self.assertFalse(gewechselt["kopf_stabil"])
def test_teaser_getrennt_gezaehlt(self):
l = r.nutzlast([
fassung(6, 0, "Gleicher Titel bleibt stehen", "Alter Teaser mit Text"),
fassung(6, 30, "Gleicher Titel bleibt stehen", "Neuer Teaser mit Text"),
])
self.assertEqual(l["titel_aenderungen"], 0)
self.assertEqual(l["teaser_aenderungen"], 1)
self.assertIsNotNone(l["teaser_art"])
self.assertEqual(l["art"], "unveraendert")
def test_wortlisten_gekappt(self):
lang = " ".join(f"wort{i}" for i in range(40))
l = r.nutzlast([fassung(7, 0, "Ein kurzer Titel steht hier"),
fassung(7, 10, lang)])
self.assertLessEqual(len(l["hinzu"]), r.WOERTER_MAX)
self.assertTrue(l["gekappt"])
def test_keine_zeichenkette_ueber_hundert(self):
# Spiegelt den Constraint codings_kein_volltext.
lang = "Ein sehr langer Titel " * 12
l = r.nutzlast([fassung(8, 0, lang), fassung(8, 10, lang + " Nachtrag")])
for wert in _zeichenketten(l):
self.assertLessEqual(len(wert), 100, wert)
def _zeichenketten(wert):
if isinstance(wert, str):
yield wert
elif isinstance(wert, dict):
for v in wert.values():
yield from _zeichenketten(v)
elif isinstance(wert, list):
for v in wert:
yield from _zeichenketten(v)
if __name__ == "__main__":
unittest.main()