Kansho/backend/writing_profile_infer.py
2026-08-25 13:57:23 +02:00

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 ""