HEX
Server: LiteSpeed
System: Linux houston.panomity.com 6.8.0-100-generic #100-Ubuntu SMP PREEMPT_DYNAMIC Tue Jan 13 16:40:06 UTC 2026 x86_64
User: nudepix (1011)
PHP: 7.4.33
Disabled: pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,pcntl_unshare,
Upload Files
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 Negations­wö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 Negations­wö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 Token­distanz zwischen zwei Negations­wörtern
        (Default 4).
    per_sentence_threshold : int, optional
        Mindestzahl von Negations­markern 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 Negations­wö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]:
    """Hilfs­funktion: 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 Satz­abstand 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