fix: bekannte Whisper-Halluzinationen filtern statt als Anliegen zu werten

"Untertitelung des ZDF, 2020" (Whisper-Trainingsdaten-Artefakt bei Stille/
sofortigem Auflegen) tauchte 5x identisch auf sehr kurzen Aufnahmen (1,7-4s)
auf und wurde von der Kategorisierung als echtes Anliegen fehlinterpretiert.
transcribe.py erkennt jetzt eine kleine Liste bekannter Whisper-
Halluzinationsphrasen (ZDF/WDR/NDR/SWR-Untertitel-Boilerplate u.ae.) und
leert den Text stattdessen.

Dabei aufgefallen: categorize.kategorisiere() wirft bei leerem Transkript
absichtlich einen Fehler - das griff bisher nie, weil Whisper nie leer
zurueckkam. Ohne Fix waere jedes "Anrufer legt nach dem Signalton sofort
auf" (normales, haeufiges Verhalten) jetzt als Kategorisierungs-Fehler nach
telefon/fehler/ verschwunden statt auf dem Board zu erscheinen.
process_call.verarbeite() faengt das jetzt ab und legt einen regulaeren
"kein Anliegen hinterlassen"-Datensatz an (sonstiges/niedrig).

Die 5 bereits gespeicherten ZDF-Fehleintraege in ablage/ wurden manuell
nachkorrigiert (transkript/auswertung), Stapel-/Archiv-Status blieb
unangetastet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vtvcyNkapuDh583643hNs
This commit is contained in:
Jeuner 2026-08-24 20:50:30 +02:00
parent 2e767f3737
commit cc569d1ecb
2 changed files with 49 additions and 2 deletions

View file

@ -28,7 +28,24 @@ def verarbeite(audio: str, anrufer_nummer: str | None = None,
print(f" Text: {t.text[:160]}{'' if len(t.text) > 160 else ''}") print(f" Text: {t.text[:160]}{'' if len(t.text) > 160 else ''}")
print("▸ Kategorisiere …") print("▸ Kategorisiere …")
auswertung = categorize.kategorisiere(t.text) if t.text.strip():
auswertung = categorize.kategorisiere(t.text)
else:
# Kein Text erkannt: Anrufer hat nach dem Signalton nicht gesprochen
# oder sofort aufgelegt (oder eine bekannte Whisper-Halluzination
# wurde rausgefiltert, siehe transcribe._ist_bekannte_halluzination).
# Das ist ein normaler, haeufiger Fall - kein Kategorisierungs-Fehler,
# der die Aufnahme nach telefon/fehler/ verschwinden lassen sollte.
print(" Kein Text erkannt - kein Anliegen hinterlassen.")
auswertung = {
"kategorie": "sonstiges", "dringlichkeit": "niedrig",
"anrufer_name": None, "rueckrufnummer": None,
"rueckruf_gewuenscht": False, "sprache": t.sprache or "de",
"anliegen_kurz": "Kein Anliegen hinterlassen - Anrufer hat nach "
"dem Signalton nicht gesprochen oder sofort "
"aufgelegt.",
"stichworte": [],
}
print(f" Kategorie: {auswertung['kategorie']} · Dringlichkeit: {auswertung['dringlichkeit']}") print(f" Kategorie: {auswertung['kategorie']} · Dringlichkeit: {auswertung['dringlichkeit']}")
protokoll.schreibe( protokoll.schreibe(
"notfall" if auswertung["dringlichkeit"] == "notfall" else "anruf", "notfall" if auswertung["dringlichkeit"] == "notfall" else "anruf",

View file

@ -12,6 +12,7 @@ from __future__ import annotations
import json import json
import os import os
import re
import subprocess import subprocess
import tempfile import tempfile
from dataclasses import dataclass, field from dataclasses import dataclass, field
@ -30,6 +31,30 @@ class Transkript:
segmente: list[dict] = field(default_factory=list) segmente: list[dict] = field(default_factory=list)
# Bekannte Whisper-Halluzinationen: bei Stille/Rauschen (z.B. Anrufer legt
# gleich nach dem Signalton auf) erfindet Whisper haeufig auswendig gelernte
# Untertitel-Textbausteine aus den Trainingsdaten, statt "kein Text" zu
# liefern - "Untertitelung des ZDF, 2020" trat bei uns mehrfach auf identisch
# kurzen (1,7-4s) Aufnahmen auf und wurde anschliessend von der Kategorisierung
# als echtes Anliegen fehlinterpretiert. whisper.cpp's eingebauter
# no-speech-Schwellwert (--no-speech-thold, Default 0.6) faengt das nicht
# zuverlaessig ab. Statt einer echten VAD (zusaetzliches Modell noetig) reicht
# hier ein Blocklist-Abgleich gegen die dokumentiert haeufigsten Phrasen.
_BEKANNTE_HALLUZINATIONEN = [
re.compile(p, re.IGNORECASE) for p in (
r"untertitel(ung)?\s+(des|im auftrag des|von)\s+(zdf|wdr|ndr|swr|br|mdr|hr|rbb)",
r"copyright\s+(wdr|zdf|ndr|swr|br)\s*\d{4}",
r"untertitel\s+von\s+stephanie\s+geiges",
r"vielen\s+dank\s+f(ü|u)rs?\s+zuschauen",
r"amara\.org",
)
]
def _ist_bekannte_halluzination(text: str) -> bool:
return any(m.search(text) for m in _BEKANNTE_HALLUZINATIONEN)
def _letzte_zeile(ausgabe: str | None) -> str: def _letzte_zeile(ausgabe: str | None) -> str:
"""Letzte nicht-leere Zeile einer Fehlerausgabe (für knappe Meldungen).""" """Letzte nicht-leere Zeile einer Fehlerausgabe (für knappe Meldungen)."""
zeilen = [z for z in (ausgabe or "").strip().splitlines() if z.strip()] zeilen = [z for z in (ausgabe or "").strip().splitlines() if z.strip()]
@ -117,6 +142,8 @@ def _mit_whispercpp(audio: Path) -> Transkript:
text_ganz = " ".join(s["text"] for s in segmente).strip() text_ganz = " ".join(s["text"] for s in segmente).strip()
sprache = roh.get("result", {}).get("language", config.WHISPER_SPRACHE) sprache = roh.get("result", {}).get("language", config.WHISPER_SPRACHE)
dauer = segmente[-1]["ende"] if segmente else 0.0 dauer = segmente[-1]["ende"] if segmente else 0.0
if _ist_bekannte_halluzination(text_ganz):
text_ganz, segmente = "", []
return Transkript(text=text_ganz, sprache=sprache, return Transkript(text=text_ganz, sprache=sprache,
sprache_wahrscheinlichkeit=1.0, dauer=dauer, segmente=segmente) sprache_wahrscheinlichkeit=1.0, dauer=dauer, segmente=segmente)
@ -139,8 +166,11 @@ def _mit_faster_whisper(audio: Path) -> Transkript:
if t: if t:
teile.append(t) teile.append(t)
seg_liste.append({"start": round(s.start, 2), "ende": round(s.end, 2), "text": t}) seg_liste.append({"start": round(s.start, 2), "ende": round(s.end, 2), "text": t})
text_ganz = " ".join(teile).strip()
if _ist_bekannte_halluzination(text_ganz):
text_ganz, seg_liste = "", []
return Transkript( return Transkript(
text=" ".join(teile).strip(), sprache=info.language, text=text_ganz, sprache=info.language,
sprache_wahrscheinlichkeit=round(info.language_probability, 3), sprache_wahrscheinlichkeit=round(info.language_probability, 3),
dauer=round(info.duration, 2), segmente=seg_liste, dauer=round(info.duration, 2), segmente=seg_liste,
) )