170 lines
6.1 KiB
Python
170 lines
6.1 KiB
Python
"""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 ""
|