File: //opt/textanalyse/tools/style_check_tool.py
# tools/style_check_tool.py
from __future__ import annotations
try:
import spacy
except Exception as e:
print(f"Warning: spaCy import disabled in style_check_tool: {e}")
spacy = None
import re
from typing import List, Dict, Any
from collections import Counter
# Laden eines deutschen spaCy-Modells (z.B. mittlere Größe)
# Stub: Lade Modell nur, wenn spacy import erfolgreich war
if spacy:
try:
nlp = spacy.load("de_core_news_md")
except Exception as e:
print(f"Warning: could not load spaCy model in style_check_tool: {e}")
nlp = None
else:
nlp = None
# --- Listen für Regeln ---
# Umfassendere Liste basierend auf [30, 31]
FILLER_WORDS = [
"anscheinend", "eigentlich", "folgendermaßen", "ausnahmslos", "ein bisschen", "fortwährend",
"augenscheinlich", "einfach", "fraglos", "ausdrücklich", "einigermaßen", "freilich",
"bei weitem", "einmal", "ganz gewiss", "besonders", "endlich", "ganz und gar", "bestenfalls",
"erheblich", "gar", "bestimmt", "etwa", "gelegentlich", "demgegenüber", "etwas", "genau",
"echt", "fast", "gerade", "geradezu", "hier und da", "in gewisser Weise", "gesagt",
"ich glaube", "infolgedessen", "gewiss", "ich sage mal", "inzwischen", "gewissermaßen",
"im Prinzip", "irgend", "gewöhnlich", "immer", "irgendwann", "gleichsam", "in Wahrheit",
"irgendwie", "grundsätzlich", "in aller Deutlichkeit", "irgendwo", "gänzlich", "in der Regel",
"ja", "halt", "in diesem Zusammenhang", "jede", "hervorragend", "in etwa", "kaum",
"keinesfalls", "meist", "nie", "keineswegs", "meistenteils", "niemals", "letzten Endes",
"mutmaßlich", "normalerweise", "letztendlich", "möglicherweise", "nun", "mal", "nachhaltig",
"nur", "man könnte sagen", "natürlich", "offenbar", "manchmal", "nämlich", "offenkundig",
"maßgeblich", "nicht wahr", "oft", "mehrere", "nichtsdestotrotz", "ohne Umschweife",
"meiner Meinung nach", "nichtsdestoweniger", "ohne Zweifel", "plötzlich", "sozusagen",
"weitgehend", "praktisch", "streng", "wenige", "regelrecht", "unbedingt", "wenigstens",
"relativ", "ungefähr", "wieder", "ruhig", "unlängst", "wieder einmal", "schon", "unsinnige",
"wirklich", "sehr", "ursprünglich", "wohl", "selbstredend", "vergleichsweise", "ziemlich",
"seltsamerweise", "vielfach", "zugegeben", "sicher", "vielleicht", "zweifellos", "sicherlich",
"sogar", "völlig", "vollkommen", "überhaupt", "sogleich", "vor allemwohl",
"übrigens", "sonst", "wahrscheinlich",
"im grunde genommen", "vom prinzip her", "wie gesagt", "mehr oder weniger", "jetzt mal ganz ehrlich", "also", "quasi", "äh", "ähm", "hm", "ich würde sagen", "und so weiter", "et cetera"
]
# Beispielhafte Liste, muss erweitert werden
CLICHES = [
r"am ende des tages",
r"das beste aus beiden welten",
r"mit herz und seele",
r"in der heutigen zeit",
r"wie sand am meer",
r"aller guten dinge sind drei",
r"auf herz und nieren prüfen",
r"das a und o",
r"das gewisse etwas",
r"der frühe vogel.*wurm",
r"die nadel im heuhaufen",
r"ein tropfen auf den heißen stein",
r"ende gut, alles gut",
r"es ist noch kein meister vom himmel gefallen",
r"ins kalte wasser springen",
r"kein wunder, dass",
r"mit allen wassern gewaschen",
r"mit dem feuern spielen",
r"mit rechten dingen zugehen",
r"nicht das gelbe vom ei",
r"ohne wenn und aber",
r"perlen vor die säue",
r"reinen tisch machen",
r"schlafende hunde wecken",
r"stille wasser sind tief",
r"über den tellerrand schauen",
r"unter den teppich kehren",
r"wie aus dem ei gepellt",
r"wie die faust aufs auge",
r"wie ein offenes buch",
r"wie ein rotes tuch",
r"wie im schlaraffenland",
r"wie man in den wald ruft.*so schallt es heraus",
r"zu guter letzt",
r"zu neuen ufern aufbrechen",
r"das leben ist kein ponyhof",
r"der apfel fällt nicht weit vom stamm",
r"die hoffnung stirbt zuletzt",
r"die qual der wahl",
r"ein bild sagt mehr als tausend worte",
r"eine hand wäscht die andere",
r"ende der fahnenstange",
r"ins gras beißen",
r"jemandem das wasser reichen",
r"mit dem kopf durch die wand",
r"mit harten bandagen kämpfen",
r"mit offenen karten spielen",
r"nicht alle tassen im schrank haben",
r"nicht das gelbe vom ei",
r"sich ins eigene fleisch schneiden",
r"sich wie ein fisch im wasser fühlen",
r"über den eigenen schatten springen",
r"unter die haut gehen",
r"wie ein elefant im porzellanladen",
r"wie ein leuchtendes beispiel",
r"wie ein schlag ins gesicht",
r"wie ein stein vom herzen fallen",
r"wie ein wunder",
r"wie im bilderbuch",
r"wie im schlaraffenland",
r"wie sand am meer",
r"wie von selbst",
r"zu allem überfluss",
r"zu guter letzt",
r"zu neuen ufern aufbrechen",
r"zwei fliegen mit einer klappe schlagen",
r"das maß ist voll",
r"den nagel auf den kopf treffen",
r"den teufel an die wand malen",
r"die erste geige spielen",
r"ein auge zudrücken",
r"ein haar in der suppe finden",
r"ein herz und eine seele sein",
r"ein tropfen auf den heißen stein",
r"eine extrawurst bekommen",
r"eine lange leitung haben",
r"etwas auf die leichte schulter nehmen",
r"etwas aus dem ärmel schütteln",
r"etwas auf die hohe kante legen",
r"etwas durch die blume sagen",
r"etwas im schilde führen",
r"etwas in den wind schlagen",
r"etwas ist faul im staate danemark",
r"etwas unter den tisch fallen lassen",
r"jemandem einen bären aufbinden",
r"jemandem einen strich durch die rechnung machen",
r"jemandem ins handwerk pfuschen",
r"jemandem reinen wein einschenken",
r"jemanden auf die palme bringen",
r"jemanden auf den arm nehmen",
r"jemanden auf den richtigen dampfer bringen",
r"jemanden auf trab bringen",
r"jemanden aus der fassung bringen",
r"jemanden das handwerk legen",
r"jemanden im stich lassen",
r"jemanden in die flucht schlagen",
r"jemanden in die schranken weisen",
r"jemanden in schach halten",
r"jemanden in verlegenheit bringen",
r"jemanden übers ohr hauen",
r"mit allen wassern gewaschen sein",
r"mit dem feuer spielen",
r"mit dem kopf durch die wand",
r"mit dem rechten fuß aufstehen",
r"mit offenen armen empfangen",
r"mit offenen karten spielen",
r"nicht das gelbe vom ei sein",
r"perlen vor die säue werfen",
r"reinen tisch machen",
r"schlafende hunde wecken",
r"stille wasser sind tief",
r"über den eigenen schatten springen",
r"unter die haut gehen",
r"wie aus dem ei gepellt sein",
r"wie ein elefant im porzellanladen",
r"wie ein offenes buch sein",
r"wie ein schlag ins gesicht",
r"wie ein wunder",
r"zwei seiten einer medaille",
r"besser spät als nie",
r"blut ist dicker als wasser",
r"um den heißen brei reden",
r"jemandem honig ums maul schmieren",
r"auf keinen grünen zweig kommen",
r"die spitze des eisbergs",
r"alle tassen im schrank haben",
r"jemandem schwimmen die felle davon",
# ... beliebig erweiterbar
]
# --- Hilfsfunktionen für Stilprüfungen ---
def find_filler_words(doc: spacy.tokens.Doc) -> List[Dict[str, Any]]:
""" Findet Füllwörter basierend auf der Liste FILLER_WORDS. """
issues = []
for token in doc:
# Prüfe auf exakten Match oder Lemma-Match
if token.text.lower() in FILLER_WORDS or token.lemma_.lower() in FILLER_WORDS:
issues.append({
'ruleId': 'FUELLWORT',
'message': f"Mögliches Füllwort: '{token.text}'",
'offset': token.idx,
'length': len(token.text),
'suggestions': [], # Vorschläge sind hier schwierig
})
return issues
def check_sentence_length(doc: spacy.tokens.Doc, threshold: int = 25) -> List[Dict[str, Any]]:
""" Prüft auf überlange Sätze. """
issues = []
for sent in doc.sents:
if len(sent) > threshold:
issues.append({
'ruleId': 'SATZLAENGE',
'message': f"Sehr langer Satz ({len(sent)} Wörter). Erwägen Sie eine Aufteilung.",
'offset': sent.start_char,
'length': sent.end_char - sent.start_char,
'suggestions': [],
})
return issues
def find_passive_sentences(doc: spacy.tokens.Doc) -> List[Dict[str, Any]]:
""" Findet potenzielle Passivsätze mittels SpaCy-Morphologie (Voice=Pass, VerbForm=Part). """
issues: List[Dict[str, Any]] = []
for sent in doc.sents:
# Suche nach Partizipien im Passiv
passive_parts = [
tok for tok in sent
if tok.morph.get("VerbForm") == ["Part"] and tok.morph.get("Voice") == ["Pass"]
]
if passive_parts:
# Markiere gesamten Satz
issues.append({
'ruleId': 'PASSIV',
'message': "Mögliche Passivkonstruktion erkannt. Aktiv formulieren?",
'offset': sent.start_char,
'length': sent.end_char - sent.start_char,
'suggestions': [],
})
return issues
def find_nominalization(doc: spacy.tokens.Doc) -> List[Dict[str, Any]]:
""" Findet potenzielle Nominalisierung (Basisimplementierung: Suffixe). """
issues = []
suffixes = ("ung", "heit", "keit", "schaft", "nis", "at", "ion", "ment", "ismus", "tum", "sal", "eur", "ade", "age", "erie", "ität", "ur", "ling") # Erweiterbar
for token in doc:
if token.pos_ == "NOUN" and token.text.lower().endswith(suffixes):
issues.append({
'ruleId': 'NOMINALSTIL_SUFFIX',
'message': f"Mögliche Nominalisierung: '{token.text}'. Verbale Formulierung prüfen.",
'offset': token.idx,
'length': len(token.text),
'suggestions': [],
})
# Nominalisierte Infinitive erkennen (z.B. 'das Laufen')
for token in doc:
if token.pos_ == "NOUN" and "Inf" in token.morph.get("VerbForm", []):
issues.append({
'ruleId': 'NOMINALSTIL_INF',
'message': f"Nominalisierter Infinitiv '{token.text}' erkannt. Verbale Konstruktion prüfen.",
'offset': token.idx,
'length': len(token.text),
'suggestions': [token.lemma_.lower()],
})
# Funktionsverbgefüge erkennen (z.B. 'in Betracht ziehen')
fv_patterns = [
r"an den tag legen",
r"an die öffentlichkeit bringen",
r"auf den geschmack kommen",
r"auf den markt bringen",
r"auf den neuesten stand bringen",
r"auf den plan rufen",
r"auf den weg bringen",
r"auf den weg machen",
r"auf die beine bringen",
r"auf die beine stellen",
r"auf die probe stellen",
r"auf die reihe bekommen",
r"aufs spiel setzen",
r"außer acht lassen",
r"außer betrieb geraten",
r"außer betrieb sein",
r"außer betrieb setzen",
r"außer betracht lassen",
r"außer frage stehen",
r"außer frage stellen",
r"außer kraft setzen",
r"außer kraft treten",
r"aus der welt schaffen",
r"aufsehen erregen",
r"aufsehen hervorrufen",
r"aufsehen wecken",
r"für aufsehen sorgen",
r"im auge behalten",
r"im gang halten",
r"im griff behalten",
r"im interesse liegen",
r"im stande sein",
r"im unklaren lassen",
r"in abhängigkeit geraten",
r"in angriff nehmen",
r"in aussicht nehmen",
r"in aussicht stellen",
r"in betracht kommen",
r"in betracht ziehen",
r"in betrieb gehen",
r"in betrieb nehmen",
r"in betrieb sein",
r"in bewegung kommen",
r"in bewegung setzen",
r"in druck geraten",
r"in druck setzen",
r"in erfahrung bringen",
r"in erscheinung treten",
r"in erwägung ziehen",
r"in frage kommen",
r"in frage stellen",
r"in gang kommen",
r"in gang bringen",
r"in gang setzen",
r"in gefahr bringen",
r"in gefahr geraten",
r"in kauf nehmen",
r"in kenntnis setzen",
r"in kraft setzen",
r"in kraft treten",
r"in stand setzen",
r"in schwierigkeiten geraten",
r"in verbindung bringen",
r"in verbindung stehen",
r"in verruf geraten",
r"in verzug geraten",
r"in zweifel ziehen",
r"ins auge fassen",
r"ins bild setzen",
r"ins leben rufen",
r"ins leben treten",
r"ins lot bringen",
r"ins lot kommen",
r"ins rechte licht rücken",
r"ins rechte licht stellen",
r"ins rollen bringen",
r"ins rollen kommen",
r"ins spiel bringen",
r"ins spiel kommen",
r"klarheit schaffen",
r"mit sich bringen",
r"pflichten nachkommen",
r"position beziehen",
r"rat einholen",
r"rechnungen tragen",
r"schluss ziehen",
r"stand halten",
r"streit schlichten",
r"teil nehmen",
r"unter beschuss geraten",
r"unter beschuss stehen",
r"unter beweis stellen",
r"unter druck setzen",
r"unter druck stehen",
r"unter kontrolle bringen",
r"unter kontrolle halten",
r"unter kontrolle stehen",
r"unter strafe stehen",
r"unter strafe stellen",
r"unter verdacht geraten",
r"unter verdacht stehen",
r"unter vertrag nehmen",
r"unter zugzwang geraten",
r"verantwortung übernehmen",
r"vorkehrungen treffen",
r"widerspruch einlegen",
r"zum abschluss bringen",
r"zum abschluss kommen",
r"zum ausdruck bringen",
r"zum ausdruck kommen",
r"zum einsatz bringen",
r"zum einsatz kommen",
r"zum erliegen bringen",
r"zum erliegen kommen",
r"zum stillstand bringen",
r"zum stillstand kommen",
r"zum vorschein bringen",
r"zum vorschein kommen",
r"zur anwendung bringen",
r"zur anwendung kommen",
r"zur auswahl stehen",
r"zur debatte stehen",
r"zur debatte stellen",
r"zur diskussion stehen",
r"zur diskussion stellen",
r"zur folge haben",
r"zur geltung bringen",
r"zur geltung kommen",
r"zur kenntnis bringen",
r"zur kenntnis nehmen",
r"zur ruhe bringen",
r"zur ruhe kommen",
r"zur sprache bringen",
r"zur sprache kommen",
r"zur verfügung haben",
r"zur verfügung halten",
r"zur verfügung sein",
r"zur verfügung stehen",
r"zur verfügung stellen",
r"zur verantwortung ziehen",
r"zur vollendung bringen",
r"zur vollendung kommen"
]
for patt in fv_patterns:
for match in re.finditer(patt, doc.text, flags=re.IGNORECASE):
issues.append({
'ruleId': 'FUNKT_VERB',
'message': f"Funktionsverbgef\u00FCge '{match.group(0)}' erkannt. Prägnantere Verbform prüfen.",
'offset': match.start(),
'length': len(match.group(0)),
'suggestions': [],
})
return issues
def find_repetitions(doc: spacy.tokens.Doc, window_size: int = 10, min_freq: int = 3) -> List[Dict[str, Any]]:
""" Findet Wortwiederholungen (Lemma-basiert) in einem gleitenden Fenster. """
issues = []
lemmas_in_window = []
tokens_in_window = []
for token in doc:
if not token.is_stop and not token.is_punct and token.is_alpha:
lemmas_in_window.append(token.lemma_.lower())
tokens_in_window.append(token)
if len(lemmas_in_window) > window_size:
lemmas_in_window.pop(0)
tokens_in_window.pop(0)
# Zähle Lemmata im aktuellen Fenster
counts = {}
for lemma in lemmas_in_window:
counts[lemma] = counts.get(lemma, 0) + 1
# Prüfe auf Wiederholungen
for lemma, count in counts.items():
if count >= min_freq:
# Finde die Positionen des wiederholten Lemmas im Fenster
repeated_tokens = [t for t, l in zip(tokens_in_window, lemmas_in_window) if l == lemma]
# Nur melden, wenn das *letzte* Token im Fenster Teil der Wiederholung ist
if tokens_in_window[-1] in repeated_tokens:
# Vermeide mehrfache Meldungen für dieselbe Wiederholungsgruppe
# (Einfache Logik: Nur melden, wenn das letzte Token das erste der Gruppe ist)
if tokens_in_window[-1] == repeated_tokens[0]:
issues.append({
'ruleId': 'WIEDERHOLUNG_WORT',
'message': f"Häufige Wiederholung von '{lemma}' ({count} Mal in {window_size} Wörtern).",
'offset': repeated_tokens[0].idx,
'length': repeated_tokens[-1].idx + len(repeated_tokens[-1].text) - repeated_tokens[0].idx,
'suggestions': [],
})
return issues
def find_cliches(doc: spacy.tokens.Doc) -> List[Dict[str, Any]]:
""" Findet abgedroschene Phrasen mittels Regex. """
issues = []
text = doc.text
for cliche_pattern in CLICHES:
# re.IGNORECASE für Groß-/Kleinschreibung
# re.finditer für mehrere Treffer und Positionen
for match in re.finditer(cliche_pattern, text, re.IGNORECASE):
issues.append({
'ruleId': 'PHRASENDRESCHEREI',
'message': f"Mögliche abgedroschene Phrase: '{match.group(0)}'",
'offset': match.start(),
'length': len(match.group(0)),
'suggestions': [],
})
return issues
def find_complex_sentences(doc, depth_threshold: int = 4, sub_clause_threshold: int = 2) -> List[Dict[str, Any]]:
"""
Identifiziert potenziell zu komplexe Sätze.
Ein Satz wird gemeldet, wenn mindestens eines gilt:
• maximale Dependenztiefe >= depth_threshold
• Anzahl subordinierender Konjunktionen >= sub_clause_threshold
Parameters
----------
doc : spacy.tokens.Doc
Von spaCy analysierter Text.
depth_threshold : int, optional
Schwelle für die Dependenztiefe (Default 4).
sub_clause_threshold : int, optional
Schwelle für die Zahl der Nebensätze (Default 2).
Returns
-------
List[Dict[str, Any]]
Liste von Issue‑Dicts mit 'offset', 'length' und 'message'.
"""
issues: List[Dict[str, Any]] = []
subordinate_conjs = {
"dass", "weil", "wenn", "ob", "bevor", "nachdem", "als",
"sobald", "solange", "falls", "während"
}
for sent in doc.sents:
# Dependenztiefe berechnen
def depth(tok):
d = 0
while tok.head != tok:
d += 1
tok = tok.head
return d
max_depth = max(depth(tok) for tok in sent)
sub_clauses = sum(
1 for tok in sent
if tok.pos_ == "SCONJ" and tok.lemma_.lower() in subordinate_conjs
)
if max_depth >= depth_threshold or sub_clauses >= sub_clause_threshold:
issues.append({
"offset": sent.start_char,
"length": len(sent.text),
"message": (
f"Satz möglicherweise zu komplex "
f"(Tiefe={max_depth}, Nebensätze={sub_clauses})."
)
})
return issues
# Adverb‑Intensivierer, die besonders häufig in Aufblähungen vorkommen
_INTENSIFIERS = {
"sehr", "extrem", "äußerst", "ungemein", "unheimlich",
"wirklich", "total", "absolut", "ziemlich", "relativ",
"besonders", "höchst", "überaus", "wahnsinnig"
}
def find_adverb_overuse(
doc,
ratio_threshold: float = 0.12,
absolute_threshold: int = 8,
intensifier_bonus: float = 0.05,
) -> List[Dict[str, Any]]:
"""
Meldet Stil‑Probleme, wenn der Text zu viele Adverbien enthält.
Kriterien (beides muss erfüllt sein):
• Anteil der ADV‑Tokens (ohne Satzzeichen) >= ratio_threshold
• Gesamtzahl ADV‑Tokens >= absolute_threshold
Intensivierer werten den Anteil leicht auf (intensifier_bonus).
Parameters
----------
doc : spacy.tokens.Doc
Voranalysierter spaCy‑Text.
ratio_threshold : float, optional
Minimaler Adverb‑Anteil am gesamten Token‑Korpus (Default 0.12).
absolute_threshold : int, optional
Minimale absolute Zahl an ADV‑Tokens (Default 8).
intensifier_bonus : float, optional
Bonus‑Faktor, der prozentual auf den Adverb‑Anteil aufgeschlagen wird,
falls Intensivierer vorkommen (Default 0.05).
Returns
-------
List[Dict[str, Any]]
Issue‑Liste mit 'offset', 'length', 'message'.
"""
# PUNKTE ausklammern, um die Quote nicht zu verfälschen
tokens_no_punct = [tok for tok in doc if not tok.is_punct]
adv_tokens = [tok for tok in tokens_no_punct if tok.pos_ == "ADV"]
if not tokens_no_punct: # leere Eingabe
return []
adv_ratio = len(adv_tokens) / len(tokens_no_punct)
# Wurde mind. ein Intensivierer benutzt? → Bonus auf Quote
has_intensifier = any(tok.lemma_.lower() in _INTENSIFIERS for tok in adv_tokens)
if has_intensifier:
adv_ratio += intensifier_bonus
# Bedingung prüfen
if len(adv_tokens) >= absolute_threshold and adv_ratio >= ratio_threshold:
first_adv = adv_tokens[0]
return [{
"offset": first_adv.idx,
"length": len(first_adv),
"message": (
f"Übermäßiger Adverb‑Einsatz: {len(adv_tokens)} ADV‑Tokens, "
f"Quote ≈ {adv_ratio:.0%}. Stil wirkt aufgeweicht."
)
}]
return []
from typing import List, Dict, Any
# ↳ Wortlisten – können jederzeit erweitert werden
_MODAL_VERB_LEMMAS = {
"können", "dürfen", "sollen", "müssen", "mögen", "wollen"
}
_WEASEL_WORDS = {
"vielleicht", "eventuell", "möglicherweise", "wohl", "vermutlich",
"angeblich", "scheinbar", "offenbar", "gewissermaßen", "relativ",
"tendenziell", "irgendwie", "prinzipiell", "theoretisch",
"quasi", "sozusagen", "ziemlich"
}
_INDEFINITE_PRONOUNS = {"man"} # „Man sollte …“
def find_modal_weasel_words(
doc,
absolute_threshold: int = 3,
ratio_threshold: float = 0.03,
) -> List[Dict[str, Any]]:
"""
Meldet Stellen, an denen zu viele Modalverben bzw. Weasel‑Words vorkommen.
Ein Issue wird erstellt, wenn *beides* zutrifft:
• Anzahl verdächtiger Tokens >= absolute_threshold
• Anteil an allen inhaltlichen Tokens >= ratio_threshold
Parameters
----------
doc : spacy.tokens.Doc
Durch spaCy analysierter Text.
absolute_threshold : int, optional
Mindestanzahl verdächtiger Tokens (Default 3).
ratio_threshold : float, optional
Mindestquote an allen Tokens ohne Satzzeichen (Default 3 %).
Returns
-------
List[Dict[str, Any]]
Issue‑Liste mit den Feldern 'offset', 'length', 'message'.
"""
if not doc:
return []
# Satzzeichen entfernen, damit die Quote nicht verfälscht wird
tokens_no_punct = [tok for tok in doc if not tok.is_punct]
suspect_tokens = []
for tok in tokens_no_punct:
lemma = tok.lemma_.lower()
text_lc = tok.text.lower()
is_modal = lemma in _MODAL_VERB_LEMMAS and tok.pos_ in {"AUX", "VERB"}
is_weasel = text_lc in _WEASEL_WORDS
is_indef_pron = text_lc in _INDEFINITE_PRONOUNS and tok.pos_ == "PRON"
if is_modal or is_weasel or is_indef_pron:
suspect_tokens.append(tok)
if not suspect_tokens:
return []
ratio = len(suspect_tokens) / len(tokens_no_punct)
if len(suspect_tokens) >= absolute_threshold and ratio >= ratio_threshold:
# Beispiele für die Fehlermeldung (max. 3 unterschiedliche)
examples, seen = [], set()
for tok in suspect_tokens:
w = tok.text.lower()
if w not in seen:
examples.append(w)
seen.add(w)
if len(examples) == 3:
break
first_tok = suspect_tokens[0]
return [{
"offset": first_tok.idx,
"length": len(first_tok),
"message": (
f"Viele Modal‑/Weasel‑Words: {len(suspect_tokens)} Treffer "
f"(z. B. {', '.join(examples)}). Aussagen wirken unkonkret."
)
}]
return []
def find_tense_switches(
doc,
switch_threshold: int = 2,
) -> List[Dict[str, Any]]:
"""
Meldet Inkonsistenzen beim Tempus‑Gebrauch.
Logik
-----
1. Für jeden Satz wird das „Haupttempus“ bestimmt
(Mehrheit der finiten Verben/Auxiliare).
Nur Präsens (Pres) und Präteritum/Imperfekt (Past) werden beachtet.
2. Beim Durchlaufen der Sätze wird gezählt, wie oft
aufeinanderfolgende Sätze das Tempus wechseln.
3. Wird `switch_threshold` überschritten, erzeugt die Funktion
ein Issue ab dem ersten problematischen Satz.
Parameters
----------
doc : spacy.tokens.Doc
Voranalysierter Text.
switch_threshold : int, optional
Zulässige Zahl an Tempus‑Wechseln (Default 2).
Returns
-------
List[Dict[str, Any]]
Issue‑Liste mit Offset/Length/Message.
"""
# Helper: bestimme Haupttempus eines Satzes („Pres“, „Past“ oder None)
def main_tense(sentence) -> str | None:
tenses = []
for tok in sentence:
if tok.pos_ in {"VERB", "AUX"}:
tense = tok.morph.get("Tense")
if tense:
t = tense[0] # spaCy liefert Liste
if t in {"Pres", "Past"}:
tenses.append(t)
if not tenses:
return None
most_common, _ = Counter(tenses).most_common(1)[0]
return most_common
# Schritt 1 – Tempus je Satz bestimmen
sent_tenses: List[str | None] = [main_tense(s) for s in doc.sents]
# Schritt 2 – Wechsel zählen
switches = 0
first_problem_sent = None
prev_tense = None
for sent, tense in zip(doc.sents, sent_tenses):
if tense is None:
continue # Keine Verben → ignorieren
if prev_tense and tense != prev_tense:
switches += 1
if first_problem_sent is None:
first_problem_sent = sent
prev_tense = tense
# Schritt 3 – Issue erzeugen
if switches >= switch_threshold and first_problem_sent is not None:
return [{
"offset": first_problem_sent.start_char,
"length": len(first_problem_sent.text),
"message": (
f"Tempus‑Inkonsistenz: {switches} Wechsel zwischen Präsens "
"und Präteritum erkannt. Prüfe Zeitformen."
)
}]
return []
def find_repeated_sentence_starts(
doc,
repeat_threshold: int = 3,
ignore: set[str] | None = None,
) -> List[Dict[str, Any]]:
"""
Meldet Passagen, in denen mehrere Sätze hintereinander
mit demselben Wort (Lemma) beginnen.
Ein Issue wird erzeugt, sobald `repeat_threshold` aufeinanderfolgende
Sätze denselben Start‑Token (Lemma, kleingeschrieben) haben.
Parameters
----------
doc : spacy.tokens.Doc
Vollständig durch spaCy analysierter Text.
repeat_threshold : int, optional
Mindestlänge einer Wiederholungssequenz (Default 3).
ignore : set[str] | None, optional
Wörter, die als Satzanfang ignoriert werden
(z. B. „und“, „aber“). Default = {und, aber, oder, dann}.
Returns
-------
List[Dict[str, Any]]
Issue‑Liste mit offset/length/message.
"""
if ignore is None:
ignore = {"und", "aber", "oder", "dann"}
issues: List[Dict[str, Any]] = []
last_start = None # Vorheriger Satzstart (Lemma)
sequence_count = 0 # Länge der aktuellen Sequenz
sequence_first_sent = None # spaCy‑Satzobjekt des Sequenzbeginns
for sent in doc.sents:
# Ersten nicht‑Punkt‑Token finden
first_token = next((t for t in sent if not t.is_punct), None)
if first_token is None:
continue
start_lemma = first_token.lemma_.lower()
# Falls ignoriert → Sequenz zurücksetzen
if start_lemma in ignore:
last_start = None
sequence_count = 0
sequence_first_sent = None
continue
# Prüfung auf Wiederholung
if start_lemma == last_start:
sequence_count += 1
else:
last_start = start_lemma
sequence_count = 1
sequence_first_sent = sent
# Erreicht die Sequenz die Meldeschwelle?
if sequence_count == repeat_threshold:
issues.append({
"offset": sequence_first_sent.start_char,
"length": len(sequence_first_sent.text),
"message": (
f"Satzanfang '{start_lemma}' wiederholt sich "
f"{sequence_count}× hintereinander."
)
})
elif sequence_count > repeat_threshold:
# Bereits gemeldet – nur Textlänge aktualisieren
issues[-1]["length"] += len(sent.text) + 1 # +1 für Leerzeichen/Separator
return issues
import re
from typing import List, Dict, Any
_PUNCT_PATTERNS = [
(re.compile(r'\.{3,}'), "Übermäßige Ellipse (…)"),
(re.compile(r'!{2,}'), "Mehrere Ausrufezeichen"),
(re.compile(r'\?{2,}'), "Mehrere Fragezeichen"),
(re.compile(r'[!?]{2,}'), "Gemischte Satzzeichenfolge"),
]
def check_punctuation_misuse(text: str) -> List[Dict[str, Any]]:
"""
Findet übermäßigen Einsatz bestimmter Satzzeichenfolgen,
z. B. „…“, „!!“, „??“, „!?“, und gibt Issues zurück.
Returns
-------
List[Dict[str, Any]]
Dicts mit 'offset', 'length', 'message'.
"""
issues: List[Dict[str, Any]] = []
for pattern, msg in _PUNCT_PATTERNS:
for m in pattern.finditer(text):
issues.append({
"offset": m.start(),
"length": m.end() - m.start(),
"message": msg
})
# Nach Position sortieren
issues.sort(key=lambda x: x["offset"])
return issues
# Regex für Wortdoppler (z. B. „die die“, „und und“)
_WORD_DOPPLER_RE = re.compile(r'\b(\w+)\s+\1\b', flags=re.IGNORECASE | re.UNICODE)
# Regex für mehrfache Leerzeichen
_DOUBLE_SPACE_RE = re.compile(r' {2,}')
def find_word_doppler(text: str) -> List[Dict[str, Any]]:
"""
Findet direkte Wortwiederholungen („Wortdoppler“) und doppelte Leerzeichen.
Liefert Issues mit den Feldern:
• offset – Position im Originaltext
• length – Länge des fehlerhaften Abschnitts
• message – Kurze Beschreibung
Parameters
----------
text : str
Rohtext (Unicode).
Returns
-------
List[Dict[str, Any]]
"""
issues: List[Dict[str, Any]] = []
# Wortdoppler
for m in _WORD_DOPPLER_RE.finditer(text):
dup_word = m.group(1)
issues.append({
"offset": m.start(),
"length": m.end() - m.start(),
"message": f"Wortdoppler: '{dup_word}' wiederholt."
})
# Mehrfache Leerzeichen
for m in _DOUBLE_SPACE_RE.finditer(text):
issues.append({
"offset": m.start(),
"length": m.end() - m.start(),
"message": "Mehrfaches Leerzeichen."
})
issues.sort(key=lambda x: x["offset"])
return issues
try:
# Liefert Worthäufigkeiten (Zipf‑Skala) für viele Sprachen
from wordfreq import zipf_frequency
_HAS_WORDFREQ = True
except ImportError: # Fallback‑Modus ohne wordfreq
_HAS_WORDFREQ = False
def _is_foreign_word(tok, freq_threshold: float = 2.0) -> bool:
"""
Heuristik: Wort gilt als „fremd“, wenn
• wordfreq‑Zipf‑Wert in Deutsch < freq_threshold,
• kein Eigennamen‑POS,
• besteht überwiegend aus lateinischen Buchstaben,
• und erfüllt nicht die ‚ß/ä/ö/ü‘‑Regel (typisch Deutsch).
"""
text = tok.text.lower()
# 1. Eigennamen ignorieren
if tok.pos_ == "PROPN":
return False
# 2. wordfreq nutzt Zipf‑Skala (0 = sehr selten, 6 = extrem häufig)
if _HAS_WORDFREQ:
freq = zipf_frequency(text, "de")
if freq >= freq_threshold:
return False
else:
# Grobes Fallback: Häufige deutsche Funktionswörter ignorieren
if text in {"und", "der", "die", "das", "ein", "eine",
"ist", "war", "in", "mit", "auf", "für",
"dass", "nicht", "wie", "auch", "zu"}:
return False
# 3. Enthält typische deutsche Sonderzeichen? → eher kein Fremdwort
if re.search(r'[äöüß]', text):
return False
# 4. Buchstabenprüfung (keine Zahlen/ASCII‑Mix)
if not re.fullmatch(r'[a-zA-Z\-]+', text):
return False
# 5. Heuristische englische Endungen (ing, tion, ment, ness etc.)
if re.search(r'(ing|tion|ment|ness|ity|able|ous|less|ize|ise)s?$', text):
return True
# 6. Default: wenn 1‑4 nicht zutrafen, als Fremdwort werten
return True
def find_foreign_word_density(
doc,
ratio_threshold: float = 0.05, # ≥ 5 % Fremdwörter
absolute_threshold: int = 5, # ≥ 5 Fremdwörter
freq_threshold: float = 2.0 # Zipf‑Grenze (nur bei wordfreq)
) -> List[Dict[str, Any]]:
"""
Meldet überhöhte Fremdwort‑/Anglizismen‑Dichte.
Ein Issue wird ausgegeben, wenn *beide* Bedingungen erfüllt sind:
• Anzahl Fremdwörter ≥ absolute_threshold
• Fremdwörter‑Quote ≥ ratio_threshold
Parameters
----------
doc : spacy.tokens.Doc
Von spaCy analysierter Text.
ratio_threshold : float
Minimaler Anteil Fremdwörter an allen alpha‑Tokens.
absolute_threshold : int
Minimale absolute Zahl an Fremdwörtern.
freq_threshold : float
Zipf‑Schwelle für wordfreq‑Abfrage (nur falls wordfreq installiert).
Returns
-------
List[Dict[str, Any]]
Liste mit einem Issue‑Dict oder leer.
"""
alpha_tokens = [tok for tok in doc if tok.is_alpha]
if not alpha_tokens:
return []
foreign_tokens = [
tok for tok in alpha_tokens
if _is_foreign_word(tok, freq_threshold=freq_threshold)
]
if len(foreign_tokens) < absolute_threshold:
return []
ratio = len(foreign_tokens) / len(alpha_tokens)
if ratio < ratio_threshold:
return []
first_tok = foreign_tokens[0]
example_words = {tok.text for tok in foreign_tokens[:5]}
return [{
"offset": first_tok.idx,
"length": len(first_tok),
"message": (
f"Hohe Fremdwort‑/Anglizismen‑Dichte: "
f"{len(foreign_tokens)} Wörter ({ratio:.0%}), "
f"z. B. {', '.join(example_words)}."
)
}]
# Grundwortschatz an Negationswörtern (Lemmas, kleingeschrieben)
_NEGATION_LEMMAS = {
"nicht", "kein", "keine", "keiner", "keines", "keinem", "keinen",
"nichts", "niemand", "nie", "niemals"
}
def find_negation_cascades(
doc,
window: int = 4,
per_sentence_threshold: int = 2
) -> List[Dict[str, Any]]:
"""
Meldet doppelte / verschachtelte Negationen, z. B.
• „nicht unwahrscheinlich“
• „nicht kein Problem“
• „kein Mensch sagt niemals …“
Ein Issue entsteht, wenn in einem Satz
• zwei Negationswörter innerhalb eines Token‑Fensters `window`
auftreten **oder**
• „nicht“ direkt vor einem ADJ/ADV/NOUN mit „un‑“ steht.
Parameters
----------
doc : spacy.tokens.Doc
Vollständig analysierter Text.
window : int, optional
Maximale Tokendistanz zwischen zwei Negationswörtern
(Default 4).
per_sentence_threshold : int, optional
Mindestzahl von Negationsmarkern pro Satz, damit ein
genereller (nicht fensterbasierter) Alarm ausgelöst wird.
(Default 2)
Returns
-------
List[Dict[str, Any]]
Issues mit 'offset', 'length', 'message'.
"""
issues: List[Dict[str, Any]] = []
for sent in doc.sents:
neg_positions = [
i for i, tok in enumerate(sent)
if tok.lemma_.lower() in _NEGATION_LEMMAS
]
# ----- 1) „nicht“ + „un‑X“‑Muster --------------------------
for i, tok in enumerate(sent):
if tok.lemma_.lower() == "nicht":
# Nächstes inhaltliches Token suchen
for j in range(i + 1, min(i + 3, len(sent))):
nxt = sent[j]
if nxt.is_punct:
continue
if nxt.text.lower().startswith("un") and nxt.pos_ in {"ADJ", "ADV", "NOUN"}:
issues.append({
"offset": tok.idx,
"length": nxt.idx + len(nxt) - tok.idx,
"message": "Doppelte Verneinung („nicht“ + „un‑…“)."
})
break
# nach Meldung für dieses „nicht“ nicht weiterprüfen
continue
# ----- 2) Zwei Negationswörter im Fenster ------------------
for i in range(len(neg_positions)):
for j in range(i + 1, len(neg_positions)):
if neg_positions[j] - neg_positions[i] <= window:
t1 = sent[neg_positions[i]]
t2 = sent[neg_positions[j]]
issues.append({
"offset": t1.idx,
"length": t2.idx + len(t2) - t1.idx,
"message": "Nahe beieinander liegende doppelte Negation."
})
break # pro Erst‑Negation nur eine Meldung
# ----- 3) Viele Negationen in einem Satz -------------------
if len(neg_positions) >= per_sentence_threshold + 1:
first_tok = sent[neg_positions[0]]
issues.append({
"offset": first_tok.idx,
"length": len(sent.text),
"message": (
f"Mehrfache Negationen ({len(neg_positions)}×) "
"im selben Satz – Aussage könnte schwer verständlich sein."
)
})
# Überschneidungen könnten doppelt gemeldet sein – sortieren
issues.sort(key=lambda x: x["offset"])
return issues
# ‑‑‑ Pronomen-Liste für die Heuristik (kannst du jederzeit erweitern) ──────
_PRONOUNS: set[str] = {
"er", "sie", "ihn", "ihm", "ihre", "ihres", "ihrem", "ihren",
"es", "sein", "seine", "seiner", "seinem", "seinen",
"dieser", "diese", "dieses", "jenes", "jener", "jene",
"man", "seiner", "seines", "seine", "dessen", "deren"
}
def _sentence_index_map(doc) -> dict[int, int]:
"""Hilfsfunktion: mappt Token‐Startindex jedes Satzes → laufende Satznummer"""
return {sent.start: i for i, sent in enumerate(doc.sents)}
def find_anaphora_distance(
doc,
max_sent_distance: int = 1, # zulässiger Satzabstand Pronomen/Antezedens
use_coref: bool | None = None # True=erzwingen, False=deaktivieren, None=Auto
) -> List[Dict[str, Any]]:
"""
Meldet Pronomen ohne klaren Bezug oder mit zu großer Anapher‑Distanz.
• Wenn Coreferee aktiv ist (``doc._.coref_chains`` vorhanden) →
benutze die Coref‑Chains.
• Sonst (oder falls ``use_coref=False``) → fallback‑Heuristik
auf Basis von Genus/Numerus und Abstand.
Gibt eine Liste von Issue‑Dicts mit
offset | length | message
zurück.
"""
issues: List[Dict[str, Any]] = []
# ── 1) Coreference‑basierte Prüfung ────────────────────────────────────
has_coref = hasattr(doc._, "coref_chains") and getattr(doc._, "coref_chains")
if use_coref is True and not has_coref:
raise RuntimeError(
"Coreference‑Resolver nicht verfügbar – "
"siehe Anleitung für die Coreferee‑Installation."
)
if (use_coref is None and has_coref) or use_coref is True:
chains = doc._.coref_chains
sent_idx_map = _sentence_index_map(doc)
for mention in chains.iter_mentions():
root = mention.root
if root.pos_ != "PRON":
continue # nur Pronomen prüfen
# vorherige Mentions der gleichen Chain suchen
prev_mentions = [
m for m in chains.get_chain(mention.chain_id)
if m.start_char < mention.start_char
]
if not prev_mentions:
# kein Antezedens
issues.append({
"offset": root.idx,
"length": len(root),
"message": "Pronomen ohne erkennbares Antezedens."
})
continue
last_prev = prev_mentions[-1].root
sent_dist = abs(
sent_idx_map[last_prev.sent.start] - sent_idx_map[root.sent.start]
)
if sent_dist > max_sent_distance:
issues.append({
"offset": root.idx,
"length": len(root),
"message": (
f"Pronomen bezieht sich auf Antezedens "
f"{sent_dist} Sätze zuvor – möglicherweise unklar."
)
})
issues.sort(key=lambda x: x["offset"])
return issues
# ── 2) Heuristische Prüfung ohne Coreferee ─────────────────────────────
sents = list(doc.sents)
for i, sent in enumerate(sents):
for tok in sent:
if tok.pos_ != "PRON" or tok.lemma_.lower() not in _PRONOUNS:
continue
antecedent_found = False
# rückwärts bis max_sent_distance Sätze schauen
for j in range(i, max(-1, i - max_sent_distance - 1), -1):
for cand in sents[j]:
if cand.idx >= tok.idx:
break # nur frühere Tokens
if cand.pos_ not in {"NOUN", "PROPN"}:
continue
# grobe Kongruenzprüfung: Genus und/oder Numerus teilen Merkmal
pron_feats, cand_feats = tok.morph, cand.morph
share_num = bool(set(pron_feats.get("Number")) & set(cand_feats.get("Number")))
share_gen = bool(set(pron_feats.get("Gender")) & set(cand_feats.get("Gender")))
if share_num or share_gen:
antecedent_found = True
break
if antecedent_found:
break
if not antecedent_found:
issues.append({
"offset": tok.idx,
"length": len(tok),
"message": "Pronomen ohne klares Antezedens."
})
issues.sort(key=lambda x: x["offset"])
return issues
# --- Hauptfunktion ---
def check_style_free(text: str) -> List[Dict[str, Any]]:
"""
Analysiert den Text auf verschiedene Stilprobleme und gibt eine Liste von Issues zurück.
"""
if nlp is None:
print("spaCy model not loaded. Cannot perform style check.")
return []
doc = nlp(text)
all_issues = []
# Einzelne Prüfungen aufrufen und Ergebnisse sammeln
all_issues.extend(check_sentence_length(doc))
all_issues.extend(find_filler_words(doc))
all_issues.extend(find_passive_sentences(doc))
all_issues.extend(find_nominalization(doc))
all_issues.extend(find_repetitions(doc))
all_issues.extend(find_cliches(doc))
all_issues.extend(find_complex_sentences(doc))
all_issues.extend(find_adverb_overuse(doc))
all_issues.extend(find_modal_weasel_words(doc))
all_issues.extend(find_tense_switches(doc))
all_issues.extend(find_repeated_sentence_starts(doc))
all_issues.extend(check_punctuation_misuse(text))
all_issues.extend(find_word_doppler(text))
all_issues.extend(find_foreign_word_density(doc))
all_issues.extend(find_negation_cascades(doc))
all_issues.extend(find_anaphora_distance(doc))
# Sortieren der Issues nach Position im Text
all_issues.sort(key=lambda x: x['offset'])
return all_issues
# Alias for backward compatibility
check_style = check_style_free