#!/usr/bin/env python3 """Phase 2, Modul: nachtraeglich geaenderte Schlagzeilen (ebene = 'revision'). Ingest schreibt jede geaenderte Fassung eines Items nach raw_item_versionen fort. Dieses Modul vergleicht die Fassungen und sagt, *wie* ein Haus seine Schlagzeile umgeschrieben hat: Tippfehler, Kuerzung, Zuspitzung, Neufassung. python3 revisionen.py --backfill python3 revisionen.py --slot 2026-09-07T08:00 python3 revisionen.py --backfill --trocken --stichprobe 120 Anders als beim Dublettenmodul haengt eine Zeile hier allein an den Fassungen *ihres eigenen* Items - es gibt keine Nachbarschaft, kein Datumsfenster und darum auch keinen Rand, der mitgeladen werden muesste. Ein Slot-Lauf rechnet fuer jedes beruehrte Item genau das, was ein Backfill fuer dasselbe Item rechnet (Invariante 2). Er muss nur die Items finden, von denen im Slot eine neue Fassung eingetroffen ist. Der Befund ist ausserhalb der Spezifikation; die Begruendung steht in migrations/p2-004-revisionen.sql. """ import argparse import collections import datetime as dt import difflib import re import sys import time from psycopg.types.json import Jsonb import normalisierung import version as versionsmodul from db import verbindung EBENE = "revision" # -------------------------------------------------------------------------- # Wortlisten in der Nutzlast # # codings darf keine Werkausschnitte enthalten (Constraint # codings_kein_volltext, hoechstens 100 Zeichen je Zeichenkette). Einzelne # hinzugefuegte und gestrichene Woerter sind keine Ausschnitte, sondern der # Befund selbst - ohne sie waere die Angabe "umformulierung" nicht # nachpruefbar. Gekappt wird trotzdem: aus zwei Wortlisten von je hoechstens # einem Dutzend Woertern laesst sich keine Schlagzeile zurueckbauen. # -------------------------------------------------------------------------- WOERTER_MAX = 12 # Grenze zwischen "umgeschrieben" und "neu geschrieben". 0.4 ist gesetzt, # nicht gemessen: unterhalb davon teilen zwei Schlagzeilen weniger als die # Haelfte ihrer Woerter, und von der alten Aussage bleibt nichts stehen. AEHNLICHKEIT_MIN = 0.4 # Tippfehler: genau ein Wort weicht ab, und die beiden Woerter sind einander # so aehnlich, dass es eine Korrektur und keine Ersetzung ist. Die # Laengengrenze traegt dabei die Arbeit - ohne sie gilt auch # "ARD-Sondersendung" -> "ARD-Sendung" als Tippfehler, und das ist eine # Kuerzung. TIPPFEHLER_RATIO = 0.7 TIPPFEHLER_LAENGENDELTA = 2 EINSTELLUNGEN = { "basis": "titel", "diff": "wort-sequenzvergleich", "aehnlichkeit_min": AEHNLICHKEIT_MIN, "tippfehler_ratio": TIPPFEHLER_RATIO, "tippfehler_laengendelta": TIPPFEHLER_LAENGENDELTA, "woerter_max": WOERTER_MAX, # Siehe normalisierung.normalisiere(): der Pluskasten *ist* hier die # Schlagzeile, nicht ihr Beiwerk. "pluskasten_entfernt": False, "teaser_mitklassifiziert": True, } # Formatmarken fuer fortlaufend fortgeschriebene Artikel. Ein Liveticker # wechselt seine Ueberschrift stuendlich, ohne dass eine Redaktion ihre # Darstellung revidiert haette - fuer die Frage, wer nachtraeglich # umformuliert, ist er Rauschen und muss abziehbar sein. # # Wortgrenzen sind noetig: "Liverpool" enthaelt "live". _TICKER = re.compile( r"\blive(?:ticker|blog|stream)\b|\bnewsblog\b|\bticker\b|\blive\b\s*[-:]", re.IGNORECASE) # Bewusst nicht ueber das +++-Muster: handelsblatt setzt "+++ USA +++:" als # Ressortmarke, nicht als Tickerkennzeichen. Die tagesschau fuehrt ihren # Ticker zwar in "++ ... ++", schreibt aber "Liveticker" hinein und wird # darueber erkannt. _ZAHL = re.compile(r"\d+") # Text vor dem ersten Doppelpunkt - die Rubrik, unter der ein Haus einen # Artikel fuehrt: "Liveblog zur Wahl in Sachsen-Anhalt: ...". # # Absichtlich nicht normalisierung.ressort_teilen(): das prueft zusaetzlich, # ob der Kopf hoechstens vier Woerter misst und dahinter noch ein # vollstaendiger Satz steht. Diese Vorsicht ist dort noetig, wo der Kopf # *abgeschnitten* wird - "Habeck: Wir haben uns geirrt" darf seinen nicht # verlieren. Hier wird nichts abgeschnitten, nur verglichen, und die Grenzen # schadeten: "CDU-Desaster in Sachsen-Anhalt:" hat drei Woerter, # "CDU-Desaster bei Wahl in Sachsen-Anhalt:" fuenf, und der Wechsel zwischen # beiden faende sonst nicht statt. _KOPF = re.compile(r"^\s*([^:]{2,60}):\s+\S") def teilen(titel): """(kopf, rest), beide normalisiert. Ohne Doppelpunkt ist kopf None.""" treffer = _KOPF.match(titel or "") if not treffer: return None, _norm(titel) return _norm(treffer.group(1)), _norm(titel[treffer.end(1) + 1:]) def kopf(titel): return teilen(titel)[0] def _norm(text): return normalisierung.normalisiere(text, pluskasten=False) def ticker_markiert(*texte): return any(_TICKER.search(t) for t in texte if t) def zahlen(text): """Zahlen als Multimenge. '54,4 Prozent' -> {'54': 1, '4': 1}.""" return collections.Counter(_ZAHL.findall(text or "")) def wortdiff(alt, neu): """(hinzugekommene, gestrichene) Woerter, in Reihenfolge des Vorkommens.""" a, b = _norm(alt).split(), _norm(neu).split() hinzu, entfernt = [], [] for marke, i1, i2, j1, j2 in difflib.SequenceMatcher(None, a, b).get_opcodes(): if marke in ("delete", "replace"): entfernt.extend(a[i1:i2]) if marke in ("insert", "replace"): hinzu.extend(b[j1:j2]) return hinzu, entfernt def aehnlichkeit(alt, neu): """Wortweise Uebereinstimmung zweier Fassungen, 0 bis 1.""" a, b = _norm(alt).split(), _norm(neu).split() if not a and not b: return 1.0 return difflib.SequenceMatcher(None, a, b).ratio() def klassifiziere(alt, neu): """Art der Aenderung zwischen zwei Fassungen. Die Reihenfolge der Pruefungen ist die Aussage: das Engste zuerst, damit eine Korrektur nicht als Umformulierung durchgeht. unveraendert nichts geaendert formal nur Zeichensetzung, Anfuehrungszeichen, Schreibung tippfehler ein Wort korrigiert kopfwechsel nur der Teil vor dem Doppelpunkt erweiterung nur ergaenzt - meist eine neue Tatsache kuerzung nur gestrichen umformulierung beides, aber die Aussage steht noch neufassung die Schlagzeile ist ausgetauscht 'kopfwechsel' ist eine Aussage ueber den Satzbau, nicht ueber das Gewicht: bei "Fussball-Bundesliga: X" -> "X" wechselt eine Rubrik, bei "Habeck: X" -> "Scholz: X" der Sprecher. Welcher Fall vorliegt, steht in `hinzu` und `entfernt`. Eine neunte Einstufung vergibt nur nutzlast(): 'zurueckgenommen', wenn zwischendurch geaendert wurde und am Ende die erste Fassung wieder dasteht. Zwischen zwei Fassungen ist sie nicht zu sehen. """ if alt == neu: return "unveraendert" na, nn = _norm(alt), _norm(neu) if na == nn: return "formal" if not na or not nn: # Kein vergleichbarer Wortbestand. Kommt vor, wenn eine Fassung nur # aus Satzzeichen besteht; lieber offen lassen als raten. return "unbestimmt" wa, wb = na.split(), nn.split() if len(wa) == len(wb): abweichend = [(x, y) for x, y in zip(wa, wb) if x != y] if len(abweichend) == 1: x, y = abweichend[0] if (abs(len(x) - len(y)) <= TIPPFEHLER_LAENGENDELTA and difflib.SequenceMatcher(None, x, y).ratio() >= TIPPFEHLER_RATIO): return "tippfehler" kopf_a, rest_a = teilen(alt) kopf_b, rest_b = teilen(neu) if rest_a == rest_b and kopf_a != kopf_b: return "kopfwechsel" vergleich = difflib.SequenceMatcher(None, wa, wb) hinzu = entfernt = 0 for marke, i1, i2, j1, j2 in vergleich.get_opcodes(): if marke in ("delete", "replace"): entfernt += i2 - i1 if marke in ("insert", "replace"): hinzu += j2 - j1 if hinzu and not entfernt: return "erweiterung" if entfernt and not hinzu: return "kuerzung" return "umformulierung" if vergleich.ratio() >= AEHNLICHKEIT_MIN else "neufassung" # -------------------------------------------------------------------------- # Laden # -------------------------------------------------------------------------- class Fassung: __slots__ = ("raw_item_id", "quelle", "gesehen_am", "slot", "titel", "teaser", "url") def __init__(self, raw_item_id, quelle, gesehen_am, slot, titel, teaser, url): self.raw_item_id = raw_item_id self.quelle = quelle self.gesehen_am = gesehen_am self.slot = slot self.titel = titel or "" self.teaser = teaser or "" self.url = url or "" _SPALTEN = """ select v.raw_item_id, r.quelle, v.gesehen_am, v.slot, v.titel, v.teaser, v.url from raw_item_versionen v join raw_items r on r.id = v.raw_item_id """ # gesehen_am, id: die Fassungen eines Items sind eine Folge, und ihre # Reihenfolge darf nicht davon abhaengen, wie die Datenbank die Zeilen # ausliefert. Die id entscheidet, falls zwei Fassungen dieselbe Uhrzeit # tragen - im Bestand kommt das nicht vor, verlassen wollen wir uns nicht. _ORDNUNG = " order by v.raw_item_id, v.gesehen_am, v.id" def lade(conn, slot=None): """Fassungen je Item, in zeitlicher Folge. Ohne slot der Gesamtbestand. Mit slot alle Fassungen der Items, von denen in diesem Slot eine neue Fassung eingetroffen ist - die *aelteren* Fassungen gehoeren dazu, sonst laesst sich nicht sagen, was sich geaendert hat. """ if slot is None: zeilen = conn.execute(_SPALTEN + _ORDNUNG).fetchall() else: zeilen = conn.execute( _SPALTEN + """ where v.raw_item_id in ( select raw_item_id from raw_item_versionen where slot = %s)""" + _ORDNUNG, (slot,)).fetchall() nach_item = {} for z in zeilen: nach_item.setdefault(z[0], []).append(Fassung(*z)) return nach_item # -------------------------------------------------------------------------- # Auswerten # -------------------------------------------------------------------------- def nutzlast(fassungen): """Befund zu einem Item. Zwei Blickwinkel, weil sie verschiedene Fragen beantworten: `art` und `aehnlichkeit` vergleichen die *erste mit der letzten* Fassung - was ist aus der Schlagzeile geworden. `arten` haelt jeden einzelnen Schritt fest - auf welchem Weg. """ erst, letzt = fassungen[0], fassungen[-1] uebergaenge = list(zip(fassungen, fassungen[1:])) arten = [klassifiziere(a.titel, b.titel) for a, b in uebergaenge if a.titel != b.titel] gesamt = klassifiziere(erst.titel, letzt.titel) if arten and gesamt == "unveraendert": # Die Schlagzeile wurde geaendert und wieder zurueckgedreht. Als # "unveraendert" waere das schlicht falsch: es ist der einzige Fall, # in dem ein Haus seine eigene Aenderung verwirft, und damit der # interessanteste. gesamt = "zurueckgenommen" hinzu, entfernt = wortdiff(erst.titel, letzt.titel) if arten else ([], []) koepfe = {teilen(f.titel)[0] for f in fassungen} teaser_arten = [klassifiziere(a.teaser, b.teaser) for a, b in uebergaenge if a.teaser != b.teaser] return { "fassungen": len(fassungen), "titel_aenderungen": len(arten), "teaser_aenderungen": len(teaser_arten), "url_aenderungen": sum(1 for a, b in uebergaenge if a.url != b.url), "art": gesamt, "arten": arten, "teaser_art": klassifiziere(erst.teaser, letzt.teaser) if teaser_arten else None, "aehnlichkeit": round(aehnlichkeit(erst.titel, letzt.titel), 4), "hinzu": hinzu[:WOERTER_MAX], "entfernt": entfernt[:WOERTER_MAX], "gekappt": len(hinzu) > WOERTER_MAX or len(entfernt) > WOERTER_MAX, "woerter_erst": len(_norm(erst.titel).split()), "woerter_letzt": len(_norm(letzt.titel).split()), "zahlen_geaendert": zahlen(erst.titel) != zahlen(letzt.titel), # Blieb die Rubrik ueber alle Fassungen dieselbe? Zusammen mit # `art` = neufassung ist das die Bauform einer Fortschreibung: # feste Rubrik, wechselnder Stand. "kopf_stabil": len(koepfe) == 1, "ticker_markiert": ticker_markiert(*(f.titel for f in fassungen)), "erstmals": erst.gesehen_am.isoformat(), "spanne_min": int((letzt.gesehen_am - erst.gesehen_am).total_seconds() // 60), } # -------------------------------------------------------------------------- # Schreiben # -------------------------------------------------------------------------- def schreibe(conn, nach_item, coder_version, trocken=False): """Codings ablegen, aber nur wo sich etwas geaendert hat. Jedes verarbeitete Item bekommt eine Zeile, auch ein nie geaendertes: sonst liesse sich "nicht umgeschrieben" nicht von "nicht verarbeitet" unterscheiden. konfidenz bleibt leer. Die Einstufung ist regelbasiert und trifft zu oder nicht; eine Zahl daneben behauptete eine Wahrscheinlichkeit, die das Verfahren nicht kennt. Das Mass der Aenderung steht als `aehnlichkeit` in der Nutzlast, wo es hingehoert. """ vorhanden = { r[0]: r[1] for r in conn.execute( "select raw_item_id, nutzlast from codings" " where coder_version = %s and ebene = %s", (coder_version, EBENE)) } neu = geaendert = unveraendert = 0 stapel = [] for item_id, fassungen in nach_item.items(): last = nutzlast(fassungen) alt = vorhanden.get(item_id) if alt == last: unveraendert += 1 continue if alt is None: neu += 1 else: geaendert += 1 stapel.append((item_id, coder_version, EBENE, Jsonb(last))) if stapel and not trocken: with conn.cursor() as cur: cur.executemany( """insert into codings (raw_item_id, coder_version, ebene, nutzlast) values (%s, %s, %s, %s) on conflict (raw_item_id, coder_version, ebene) do update set nutzlast = excluded.nutzlast, kodiert_am = now()""", stapel) return {"neu": neu, "geaendert": geaendert, "unveraendert": unveraendert} def protokolliere(conn, slot, coder_version, dauer_ms, gesehen, kodiert, fehler=None): conn.execute( """insert into p2_laeufe (slot, modul, coder_version, dauer_ms, items_gesehen, items_kodiert, items_fehler, fehler) values (%s, %s, %s, %s, %s, %s, %s, %s)""", (slot, EBENE, coder_version, dauer_ms, gesehen, kodiert, 0 if not fehler else 1, fehler)) # -------------------------------------------------------------------------- # Bericht und Stichprobe # -------------------------------------------------------------------------- def bericht(nach_item): lasten = {i: nutzlast(f) for i, f in nach_item.items()} geaendert = {i: l for i, l in lasten.items() if l["titel_aenderungen"]} ticker = {i for i, l in geaendert.items() if l["ticker_markiert"]} fortschreibung = {i for i, l in geaendert.items() if l["kopf_stabil"] and l["art"] == "neufassung"} print(f" Items: {len(lasten)}") print(f" mit Titelaenderung: {len(geaendert)}" f" ({sum(l['titel_aenderungen'] for l in geaendert.values())} Uebergaenge)") print(f" nur Teaser geaendert: " f"{sum(1 for i, l in lasten.items() if l['teaser_aenderungen'] and i not in geaendert)}") print(f" davon Ticker/Blog markiert: {len(ticker)}") print(f" Bauform Fortschreibung: {len(fortschreibung)}" f" (feste Rubrik, ausgetauschter Stand)") zaehler = collections.Counter(l["art"] for l in geaendert.values()) ohne = collections.Counter(l["art"] for i, l in geaendert.items() if i not in ticker and i not in fortschreibung) print("\n Art der Aenderung (erste gegen letzte Fassung):") print(f" {'':<16}{'gesamt':>8}{'ohne Fortschreibung':>22}") for art, n in zaehler.most_common(): print(f" {art:<16}{n:>8}{ohne.get(art, 0):>22}") haeuser = collections.Counter(f[0].quelle for i, f in nach_item.items() if i in geaendert and i not in ticker and i not in fortschreibung) if haeuser: print("\n Umgeschriebene Schlagzeilen je Haus (ohne Fortschreibung):") for q, n in haeuser.most_common(): print(f" {q:<16}{n:>4}") def stichprobe(nach_item, anzahl, pfad): """Fassungspaare zum Nachpruefen von Hand ausschreiben. Die Einstufung ist eine Behauptung ueber Sprache, und die kann nur ein Mensch pruefen. Nach Art gruppiert und je Art nach Aehnlichkeit sortiert, damit die Grenzfaelle einer Kategorie beieinander stehen. """ zeilen = [] for fassungen in nach_item.values(): for a, b in zip(fassungen, fassungen[1:]): if a.titel == b.titel: continue zeilen.append((klassifiziere(a.titel, b.titel), aehnlichkeit(a.titel, b.titel), a, b)) zeilen.sort(key=lambda z: (z[0], z[1], z[2].raw_item_id)) nach_art = {} for z in zeilen: nach_art.setdefault(z[0], []).append(z) # Gleichmaessig ueber die Arten ziehen: eine Stichprobe, die nur aus den # 147 Umformulierungen besteht, prueft die seltenen Kategorien nie. je_art = max(1, anzahl // max(1, len(nach_art))) gewaehlt = [] for art in sorted(nach_art): gewaehlt.extend(nach_art[art][:je_art]) for art in sorted(nach_art): for z in nach_art[art][je_art:]: if len(gewaehlt) >= anzahl: break gewaehlt.append(z) gewaehlt.sort(key=lambda z: (z[0], z[1])) with open(pfad, "w", encoding="utf-8") as f: f.write("# Revisions-Stichprobe. Spalte 'urteil' von Hand fuellen:" " j = Einstufung trifft zu, n = trifft nicht zu.\n") f.write("# Je Art aufsteigend nach Aehnlichkeit - der erste Fall einer Art" " ist ihr aeusserster.\n\n") art_zuvor = None for art, aehn, a, b in gewaehlt: if art != art_zuvor: f.write(f"\n### {art} ({len(nach_art[art])} Uebergaenge insgesamt)\n\n") art_zuvor = art f.write(f"urteil=_ art={art} aehnlichkeit={aehn:.3f} " f"item={a.raw_item_id} [{a.quelle}]\n") f.write(f" - {a.titel}\n") f.write(f" + {b.titel}\n\n") return len(gewaehlt), len(zeilen) def main(argv=None): p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--slot", help="ein Slot, z.B. 2026-09-07T08:00") p.add_argument("--backfill", action="store_true", help="Gesamtbestand") p.add_argument("--trocken", action="store_true", help="rechnen, nichts schreiben") p.add_argument("--stichprobe", type=int, metavar="N", help="N Uebergaenge zum Nachpruefen ausschreiben") p.add_argument("--stichprobe-datei", default="stichprobe-revisionen.txt") a = p.parse_args(argv) if not a.backfill and not a.slot: p.error("--backfill oder --slot angeben") slot = dt.datetime.fromisoformat(a.slot) if a.slot else None begonnen = time.monotonic() with verbindung() as conn: v = versionsmodul.eintragen( conn, komponenten=versionsmodul.komponenten(revision=EINSTELLUNGEN)) print(f"coder_version {v}{' (trocken)' if a.trocken else ''}") nach_item = lade(conn, slot=slot) if not nach_item: print(" nichts zu tun") return 0 bericht(nach_item) zahlen_ = schreibe(conn, nach_item, v, trocken=a.trocken) dauer = int((time.monotonic() - begonnen) * 1000) print(f"\n codings: neu {zahlen_['neu']}," f" geaendert {zahlen_['geaendert']}," f" unveraendert {zahlen_['unveraendert']}") print(f" Dauer: {dauer} ms") if a.stichprobe: n, gesamt = stichprobe(nach_item, a.stichprobe, a.stichprobe_datei) print(f" Stichprobe: {n} von {gesamt} Uebergaengen" f" nach {a.stichprobe_datei}") if not a.trocken: protokolliere(conn, slot, v, dauer, len(nach_item), zahlen_["neu"] + zahlen_["geaendert"]) conn.commit() else: conn.rollback() return 0 if __name__ == "__main__": sys.exit(main())