Sprecherzuordnung auf verrauschtem OCR: ein Notizbuch von Abend zu Abend
Wie ein Regex die Schweigebitte des Parlamentspräsidenten zwölfmal dem falschen Mund zuwies, und die fünf Abende, die es brauchte, um die Genauigkeit über 850 Sitzungen von 95,6 % auf 99,1 % zu heben.
Als ich das Sprecherzuordnungsmodell zum ersten Mal auf die TBMM-Sitzung vom 6. Februar 2023 anwandte, schrieb es zwölf Aussagen des Parlamentspräsidenten selbstbewusst Kemal Kılıçdaroğlu zu. Diese Sitzung wurde drei Tage nach dem Erdbeben ausgestrahlt. Der Präsident hatte elf Minuten lang um Ruhe gebeten, damit die Namen der Toten verlesen werden konnten. Keine dieser zwölf Aussagen stammte von Kılıçdaroğlu. Ich wusste es, weil ich die Sitzung in jener Nacht live im Fernsehen verfolgt hatte. Das Modell wusste es nicht.
Ich saß vor dem Terminal und fühlte eine sehr bestimmte Art von Kleinheit. Ein Modell, das eine normale Sitzung zu 95,6 % richtig zuordnet, ist ein Modell, das eine schwere Sitzung katastrophal falsch zuordnet — an genau dem Tag, an dem das Transkript am wichtigsten ist.
Dieser Beitrag ist das Notizbuch, das die Zahl von Abend zu Abend von 95,6 % auf 99,1 % gehoben hat. Es ist kein Triumphtext. Es ist die Geschichte, wie ein schlechter Regex fünf Abende brauchte, bis er gefixt war — und wie das LLM, das alle für die Antwort halten, nur vier Prozent der Arbeit erledigte.
Wie das Korpus tatsächlich aussieht
Das NLP-Parliament-Projekt verarbeitet TBMM-Sitzungsprotokolle von 1995 bis 2024. Etwa 6.000 Seiten, wenn man die PDFs zu Text flacht. Die PDFs selbst sind gescannt und dann mit OCR verarbeitet. Unterschriften bluten in den Rand. Seitenumbrüche landen mitten im Satz. Es gibt eine bestimmte horizontale Linie — sie trennt verfahrenstechnische Blöcke — die das OCR hartnäckig als eine Reihe kleiner L liest.
Das Sprecher-Tag sieht im sauberen Fall so aus:
MUSTAFA KARA (X Partisi) – Sayın Başkan, değerli milletvekilleri...Name in Großbuchstaben, Partei in Klammern, ein Geviertstrich, dann die Rede. Einfach. Ein Regex erledigt das in einer Zeile.
Im verrauschten Fall liest das OCR dieselbe Zeile so:
MUSTAFA KARA (X Parlisl) — Sayın Başkan, değerli...Drei Dinge fallen auf. Aus Partisi wurde Parlisl — die Folge "ti" wurde wegen eines schwachen Scans als "lI" gerendert. Der Geviertstrich wurde breiter. Und auf zwanzig Prozent der Seiten ist überhaupt kein Geviertstrich; das OCR verliert ihn.
Ein Regex, der im sauberen Fall zu 100 % sicher ist, scheitert im verrauschten Fall stumm. Stilles Scheitern ist das Schlimmste. Es bedeutet, dass die Rede dem vorherigen Sprecher zugeschrieben wird, weil dieser noch im Gedächtnis des Regex sitzt.
Abend 1: der Regex
Ich begann, wo alle beginnen.
SPEAKER_RE = re.compile(
r"^([A-ZÇĞİÖŞÜ ]{4,40})\s*\(([^)]+)\)\s*[–—-]\s*(.*)"
)
def attribute(lines):
current = None
for line in lines:
m = SPEAKER_RE.match(line)
if m:
# neuer Sprecherblock beginnt
current = (m.group(1).strip(), m.group(2).strip())
yield current, m.group(3)
elif current:
yield current, lineDas Muster bewältigt den sauberen Fall. Es nimmt außerdem an: Wenn kein neues Tag gefunden wird, spricht der aktuelle Sprecher weiter. Diese Annahme ist der Bug. Bei einem fehlerhaften Tag matched der Regex nicht, die Zeile wird als Fortsetzung behandelt, und der vorige Sprecher frisst die Worte des nächsten.
Am ersten Abend verbrachte ich vier Stunden mit Varianten. Mehr Bindestrich-Zeichen. Lockerere Whitespace-Regeln. Eine permissivere Parteigruppe. Jeder Fix verschob den Fehler auf eine andere Seite. Gegen 1:30 Uhr hörte ich auf, weil ich das Gefühl hatte, Genauigkeit mit Sturheit zu verwechseln.
Abend 2: Fuzzy-Match und zwölf zusammengefügte Sprecher
Am zweiten Abend gab ich exaktes Matching auf. Den Sprecherblock fuzzy gegen eine bekannte Form matchen, bis zu zwei Edits erlauben.
from rapidfuzz import fuzz
def looks_like_speaker_tag(line: str) -> bool:
# Sprecher-Tags sind kurz, meist groß, enthalten Klammern.
if "(" not in line or ")" not in line:
return False
head = line.split("–")[0] if "–" in line else line.split("—")[0]
# Großbuchstaben-Anteil in den ersten 30 Zeichen; Menschen schreien selten so.
upper = sum(c.isupper() for c in head[:30])
return upper >= 10 and fuzz.partial_ratio(head, head.upper()) > 85Das fing die fehlerhaften Tags ein. Es fing auch Dinge ein, die keine Tags waren. Großgeschriebene Abschnittsüberschriften. Seitenfußzeilen. Verfahrensmarker wie OTURUM AÇILIRKEN. In einer Sitzung identifizierte es zwölf "neue Sprecher", die es nicht gab; ihre Zeilen wurden als frische Blöcke aufgenommen und zerlegten die echten Reden in Konfetti.
Recall hoch, Precision runter. Das Modell war jetzt auf eine interessantere Weise falsch. Um Mitternacht hörte ich auf, weil anders falsch zu sein kein Fortschritt ist.
Abend 3: das Wörterbuch
Das war der Abend, an dem ich aufhörte, schlau sein zu wollen.
Ich baute ein Wörterbuch. Für jede Sitzung kannte ich das Datum. Aus dem Datum konnte ich die offizielle Abgeordnetenliste abfragen — wer vereidigt war, wer fehlte, wer seit der letzten Sitzung die Partei gewechselt hatte. Das Wörterbuch für eine Sitzung 2017 hat etwa 550 Einträge, für eine Sitzung 2023 etwa 600.
def load_speaker_dict(session_date):
# roster.json ist aus offiziellen TBMM-Akten handgepflegt.
roster = json.load(open(f"rosters/{session_date.year}.json"))
# An einem Tag können nur anwesende Mitglieder sprechen. Abwesenheit zählt.
present = roster["present_on"].get(session_date.isoformat(), roster["sworn_in"])
return {normalize(m["name"]): m for m in roster["members"] if m["id"] in present}Jetzt hatte ein Fuzzy-Match einen Anker. Der Kandidatenname wurde gegen die Anwesenheitsliste des Tages gematched, nicht gegen das Universum aller türkischen Namen. Falsche Positive sanken steil.
Dann traf ich den Fall, den ich nicht vorhergesehen hatte: Mitglieder, deren Name sich ändert. Eine Abgeordnete, die mitten in der Legislatur heiratete und den Namen ihres Mannes annahm. Ein aus seiner Partei ausgeschlossener Abgeordneter, der unabhängig weitersaß. Die Liste hatte sie so, das OCR so; das Wörterbuch verfehlte die Verbindung.
Ich fügte eine Aliastabelle hinzu. Jeder Eintrag kann eine Liste früherer Namen mit Gültigkeitsbereich haben. Am Ende des Abends hatte das Wörterbuch eine kleine Sammlung von Fußnoten, die wie winzige Biographien lesen. Manche davon sind immer noch mein Lieblingsteil des Codes.
Abend 4: Position und Form
Abend vier war der langweilige Abend, an dem die meiste Arbeit passierte.
Beim Vergleich von tausend korrekt zugeordneten und tausend falsch zugeordneten Zeilen fiel mir auf: Ein echtes Sprecher-Tag beginnt fast immer in Spalte null der Zeile, nach einer Leerzeile, und folgt entweder ein Bindestrich oder ein Hard-Newline. Verfahrenstechnische Anrufe wie BAŞKAN: verwenden einen Doppelpunkt, keinen Bindestrich, und die Folgezeile ist kurz und in Klammern.
def is_substantive_tag(prev_blank, line, next_line):
# Substantielle Sprecher bekommen einen Strich; verfahrenstechnische Aufrufe einen Doppelpunkt.
has_dash = any(d in line for d in ("–", "—"))
has_colon = line.rstrip().endswith(":")
# Echte Reden gehen in der nächsten Zeile weiter; Aufrufe sind kurz.
next_is_substance = len(next_line.strip()) > 40
return prev_blank and has_dash and not has_colon and next_is_substanceKombiniert mit dem Wörterbuch brachte das die Zuordnungsgenauigkeit auf einem ausgehaltenen Batch von 50 Sitzungen auf etwa 96 %. Wir hatten drei Abende sorgfältige Arbeit aufgewendet, um 0,4 Prozentpunkte über dem ursprünglichen Regex zu landen. So fühlt sich Produktions-NLP an.
Abend 5: das LLM, sparsam eingesetzt
Die letzten 4 % waren der lange Schwanz. Fälle, in denen das OCR so degradiert war, dass das Wörterbuch keinen Kandidaten verankern konnte. Fälle, in denen zwei Mitglieder mit ähnlichen Namen in derselben Minute sprachen. Fälle, in denen der Seitenumbruch exakt zwischen Sprecher-Tag und erstem Wort landete.
Nur dafür fügte ich einen LLM-Schiedsrichter hinzu.
def adjudicate(window, candidates, model):
# Nur aufgerufen, wenn die Heuristik-Konfidenz < 0,7 ist.
# Wir übergeben ein 6-Zeilen-Fenster und die Wörterbuch-Kandidaten des Tages.
prompt = (
"Gegeben das folgende Transkript-Fenster und die Liste der "
"in dieser Sitzung anwesenden Abgeordneten, identifiziere den "
"Sprecher der letzten Zeile. Wenn unsicher, gib 'unknown' zurück.\n\n"
f"FENSTER:\n{window}\n\nKANDIDATEN:\n{candidates}\n"
)
out = model.generate(prompt, max_tokens=40, temperature=0)
return parse_candidate(out, candidates)Der Schiedsrichter läuft auf durchschnittlich 200 Zeilen pro Sitzung. Zu aktuellen Preisen etwa drei Cent pro Sitzung. Über 850 Sitzungen hat das LLM die Fälle erledigt, die die Genauigkeit von 96 % auf 99,1 % hoben.
Die kontraintuitive Lektion steht genau dort. Das LLM löste das Problem nicht. Die langweilige Heuristik plus das handgepflegte Wörterbuch erledigten 95 % der Arbeit. Das LLM kümmerte sich nur um die letzten 4 %, was es magisch aussehen ließ. Hätte ich mit dem LLM begonnen, hätte ich ein Vermögen ausgegeben und denselben langen Schwanz immer noch bei 96 % gejagt.
Ein kleiner Zoo der OCR-Fehler
Eine nicht-erschöpfende Auswahl, gepflegt in einer Datei namens ocr_zoo.md:
Partisi → Parlisl(schwacher Scan, t→l, i→I)İSMET → ISMET(gepunktetes I auf gekippter Seite verloren)BAŞKAN → BASKAN → BAŞKAH(Cedille wandert; N→H am unteren Rand)- Geviertstrich → nichts (1 von 5 Seiten)
- Zwei Spalten verschmolzen, wenn die Mittelfalz sich wölbt
(X.Y.Z. Partisi)Punktuation reduziert zu(X Y Z Partisi)
Jeder Fall erzwang an einem Punkt einen eigenen Abend.
Was ich dem früheren Mir sagen würde
Könnte ich zu Abend eins zurück, würde ich sagen: Beginne nicht mit dem Regex. Beginne mit dem Wörterbuch. Das Wörterbuch ist das Ding, das Raten in Nachschlagen verwandelt. Alles andere ist Verzierung.
Und das Osterei, denn jedes Notizbuch braucht eins. Das Sprecher-Wörterbuch hat inzwischen 1.247 Einträge. Drei davon haben Notizen, die ⚠️ nicht dieselbe Person wie der X-Name mit ähnlicher Schreibweise sagen. Ich überlasse es dir zu raten, welche drei Namen die meiste Verwirrung stiften. Wenn du die offensichtlichen rätst, hast du bei zwei wahrscheinlich recht und beim dritten sehr unrecht.
// wenn du schon hier bist
- 11 Min Lesezeit
Die Woche, in der ein LLM eine politische Position halluzinierte — und ein Journalist sie fast zitiert hätte
Eine automatisch erzeugte TBMM-Zusammenfassung sagte, ein Abgeordneter habe über ein Gesetz abgestimmt, das gar nicht abgestimmt wurde. Der Journalist hielt die Geschichte 24 Stunden zurück. Vier Tage, um eine Verifikationsschicht zu bauen, die Nein sagen kann.
nlpllmcivic-techverification - 7 Min Lesezeit
Agentic-AI-Guardrails: Den while(true) davon abhalten, dein Token-Budget zu verbrennen
Vier Guardrails, die eine Vibes-Schleife in etwas verwandeln, das du in Produktion laufen lässt: Budget-Hülle, Retry-Kurven, Break-Bedingungen und eine echte Fallback-Kette.
agentic-aiproductionengineering-lessons