"""Local, explainable writing-style features. Not a personality or Self Model.""" from __future__ import annotations import re from journal_body import plain_text MIN_STYLE_CHARS = 40 CLOCK = re.compile(r"\b\d{1,2}:\d{2}\b|\b\d{1,2}\s*Uhr\b", re.I) TIME_WORDS = re.compile( r"\b(heute|gestern|morgen|morgens|mittags|abends|nachts|danach|vorher|später|zuerst|früh|dann)\b", re.I, ) THEMATIC = re.compile(r"\b(eigentlich|überhaupt|was mich|dabei fällt|im Grunde)\b", re.I) REFLECT = re.compile( r"\b(allerdings|trotzdem|dennoch|vielleicht|offenbar|merkwürdig|" r"wunderte|beruhig|bedeutet|irgendwie|scheint|spüre|fühl)\w*\b", re.I, ) HUMOR = re.compile(r"\b(haha|lol|witz|irgendwie lustig)\b|:\)|😉", re.I) PLACE = re.compile( r"\b(hafen|balkon|markt|strand|gassen|wohnung|bett|café|cafe|laden|hafen)\b", re.I, ) NAMEISH = re.compile(r"\b[A-ZÄÖÜ][a-zäöüß]{2,}\b") TRANSITION = re.compile( r"\b(danach|anschließend|später|schließlich|irgendwann|als ich dann|nach einer weile)\b", re.I, ) DIRECT = re.compile( r"\b(ich war|ich bin|mir war|mich hat|ich fühlte|ich spürte|traurig|wütend|glücklich|entspannt)\b", re.I, ) FACET_KEYS = ( "core", "journal", "reflective", "rhythm", "detail", "chronology", "lexicon", "humor", "emotional_directness", "transitions", "concreteness", "event_vs_reflection", ) SIGNAL_KEYS = FACET_KEYS FACET_LABELS = { "core": "Globaler Schreibkern", "journal": "Autobiografisches Journaling", "reflective": "Tiefer reflektierende Texte", "rhythm": "Satzlänge / Rhythmus", "detail": "Detailgrad", "chronology": "Erzählweise", "lexicon": "Wortwahl / typische Formulierungen", "humor": "Humor", "emotional_directness": "Emotionale Direktheit", "transitions": "Typische Übergänge", "concreteness": "Zeiten, Orte, Namen", "event_vs_reflection": "Ereignis / Reflexion", } def _blob(texts: list[str]) -> str: return " ".join(plain_text(item or "").strip() for item in texts if (item or "").strip()) def infer_features(texts: list[str]) -> dict[str, str]: """Structured local features. Empty dict if too little text.""" blob = _blob(texts) if len(blob) < MIN_STYLE_CHARS: return {} sentences = [item.strip() for item in re.split(r"[.!?]+", blob) if item.strip()] if not sentences: return {} avg = sum(len(item.split()) for item in sentences) / max(len(sentences), 1) if avg >= 18: rhythm = "typische Sätze eher lang" elif avg <= 10: rhythm = "typische Sätze eher knapp" else: rhythm = "typische Sätze von mittlerer Länge" clocks = bool(CLOCK.search(blob)) numbers = bool(re.search(r"\b\d+\b", blob)) if clocks and numbers: detail = "nennt Uhrzeiten, Zahlen und konkrete Abläufe" elif clocks or numbers: detail = "arbeitet mit konkreten Angaben" else: detail = "hält den Detailgrad eher zurück" time_hits = len(TIME_WORDS.findall(blob)) theme_hits = len(THEMATIC.findall(blob)) if time_hits >= 2 and time_hits > theme_hits: chronology = "erzählt vorwiegend chronologisch" elif theme_hits >= 2 and theme_hits >= time_hits: chronology = "ordnet eher thematisch als streng nach der Uhr" else: chronology = "Erzählweise noch unscharf" words = re.findall(r"[a-zäöüß]{5,}", blob.lower()) counts: dict[str, int] = {} for word in words: counts[word] = counts.get(word, 0) + 1 typical = [item[0] for item in sorted(counts.items(), key=lambda pair: (-pair[1], pair[0]))[:6]] lexicon = "wiederkehrende Wörter: " + ", ".join(typical) if typical else "keine auffällige Wortwahl" humor = "lässt Humor durchscheinen" if HUMOR.search(blob) else "Humor tritt kaum als Stilmittel auf" direct_hits = len(DIRECT.findall(blob)) if direct_hits >= 3: emotional = "benennt Empfinden eher direkt" elif direct_hits >= 1: emotional = "mischt Sachbericht und gelegentliche direkte Empfindung" else: emotional = "bleibt vorwiegend bei der äußeren Begebenheit" trans_hits = len(TRANSITION.findall(blob)) transitions = ( "verbindet Szenen oft mit danach/später/irgendwann" if trans_hits >= 2 else "Übergänge sind unauffällig oder selten markiert" ) places = bool(PLACE.search(blob) or NAMEISH.search(blob)) if clocks and places: concreteness = "nutzt konkrete Zeiten, Orte und Namen" elif clocks or places: concreteness = "streut konkrete Verankerungen ein" else: concreteness = "bleibt eher ohne feste Zeit- oder Ortsmarken" reflect_hits = len(REFLECT.findall(blob)) if reflect_hits >= 3: mix = "mischt Chronik mit Einordnung" journal = "autobiografischer Bericht mit reflektierenden Einschüben" reflective = "öffnet gelegentlich Bedeutung und Wirkung, ohne Diagnose" elif reflect_hits >= 1: mix = "überwiegend Chronik, mit einzelnen Einordnungen" journal = "vorwiegend autobiografische Chronik" reflective = "" else: mix = "bleibt vorwiegend bei der sachlichen Chronik" journal = "sachliche autobiografische Chronik" reflective = "" core = "; ".join( part for part in (rhythm, detail, chronology, mix) if part ) features = { "core": "Erzählmerkmale (nur aus Nutzertext, keine Diagnose): " + core + ".", "journal": journal, "reflective": reflective, "rhythm": rhythm, "detail": detail, "chronology": chronology, "lexicon": lexicon, "humor": humor, "emotional_directness": emotional, "transitions": transitions, "concreteness": concreteness, "event_vs_reflection": mix, } return {key: value for key, value in features.items() if (value or "").strip()} def compile_style_signals(texts: list[str]) -> str: """Compact form hint used by tests and as a fallback line in the brief.""" features = infer_features(texts) if not features: return "" return features.get("core") or ""