"""Stable Writing-Profile shell. Seed keys are ordering help, not a closed trait schema.""" from __future__ import annotations import re from datetime import datetime, timezone SLUG = re.compile(r"^[a-z][a-z0-9_]{1,62}$") LIFECYCLE = ("uninitialized", "initial_pending", "confirmed") LAYERS = ("core", "context", "output") TRAIT_STATUS = ("active", "merged", "split", "retired") REF_ROLES = ("evidence", "exemplar") ORIGINS = ("manual", "imported", "accepted_suggestion", "initial_build") CONTEXT_HINTS = { "vacation_diary": "autobiographical_journal", "travel_journal": "autobiographical_journal", "autobiographical_journal": "autobiographical_journal", "journal": "autobiographical_journal", "reflective": "reflective", } FACET_ALIASES = {"journal": "autobiographical_journal"} SEED_FACETS = { "core": {"layer": "core", "label": "Globaler Schreibkern"}, "autobiographical_journal": {"layer": "context", "label": "Autobiografisches Journaling"}, "reflective": {"layer": "context", "label": "Tiefer reflektierende Texte"}, } SEED_TRAIT_HINTS = ( {"slug": "rhythm", "label": "Satzlänge / Rhythmus"}, {"slug": "detail", "label": "Detailgrad"}, {"slug": "chronology", "label": "Erzählweise"}, {"slug": "lexicon", "label": "Wortwahl / typische Formulierungen"}, {"slug": "humor", "label": "Humor"}, {"slug": "emotional_directness", "label": "Emotionale Direktheit"}, {"slug": "transitions", "label": "Typische Übergänge"}, {"slug": "concreteness", "label": "Zeiten, Orte, Namen"}, {"slug": "event_vs_reflection", "label": "Ereignis / Reflexion"}, ) LEGACY_STYLE_KEYS = {item["slug"] for item in SEED_TRAIT_HINTS} EXISTING_BEFORE_NEW = ( "bestehenden Trait bestätigen", "präzisieren", "Scope / Core-vs-Facet-Zuordnung ändern", "redundante Traits zusammenführen oder sinnvoll aufteilen", "erst dann einen neuen Trait anlegen", "neue Facet nur bei tatsächlicher semantischer Notwendigkeit", ) TRAIT_ACTIONS = ( "keep", "update", "add", "remove", "reclassify", "merge", "split", "confirm", "precisify", "rescope", "create", "move", ) ACTION_ALIASES = { "keep": "keep", "confirm": "keep", "update": "update", "precisify": "update", "add": "add", "create": "add", "remove": "remove", "retire": "remove", "reclassify": "reclassify", "rescope": "reclassify", "move": "reclassify", "merge": "merge", "split": "split", } APPLY_ACTIONS = { "keep": "confirm", "update": "precisify", "add": "create", "remove": "remove", "reclassify": "rescope", "merge": "merge", "split": "split", } EVIDENCE_BASIS = ("kansho_sources", "external_chat_history", "external_context") EXTERNAL_EVIDENCE = ("external_chat_history", "external_context") BASIS_ALIASES = { "kansho_sources": "kansho_sources", "kansho": "kansho_sources", "external_chat_history": "external_chat_history", "external_context": "external_context", "external": "external_context", "dayone_export": "external_context", "day_one": "external_context", "dayone": "external_context", } ANALYSIS_MODES = ("initial_build", "review") META_STYLE_MARKERS = ( "explizite nutzer-review", "expliziter initial profile build", "explizite nutzer-review des writing profile", "expliziter initialer profilaufbau", ) def normalize_facet_key(key: str | None) -> str: raw = (key or "").strip() return FACET_ALIASES.get(raw, raw) def facet_label(key: str) -> str: item = SEED_FACETS.get(normalize_facet_key(key)) if item: return item["label"] return key.replace("_", " ") def facet_layer(key: str, fallback: str = "context") -> str: item = SEED_FACETS.get(normalize_facet_key(key)) if item: return item["layer"] return fallback if fallback in LAYERS else "context" def valid_slug(value: str | None) -> bool: return bool(value and SLUG.match(value)) def coerce_slug(value: str, fallback: str = "trait") -> str: raw = (value or "").strip().lower().replace("-", "_").replace(" ", "_") cleaned = re.sub(r"[^a-z0-9_]", "", raw) if valid_slug(cleaned): return cleaned return fallback def hint_for_context(context_hint: str | None) -> str: raw = (context_hint or "").strip().lower() return CONTEXT_HINTS.get(raw, raw) def parse_when(raw: str | None) -> datetime | None: text = (raw or "").strip() if not text: return None try: stamp = datetime.fromisoformat(text.replace("Z", "+00:00")) except ValueError: try: stamp = datetime.strptime(text[:10], "%Y-%m-%d") except ValueError: return None if stamp.tzinfo is None: stamp = stamp.replace(tzinfo=timezone.utc) return stamp def recency_weight(occurred_at: str | None, now: datetime | None = None) -> float: """Newer texts weigh more for current expression; older texts stay as long-term evidence.""" stamp = parse_when(occurred_at) if not stamp: return 1.0 current = now or datetime.now(timezone.utc) days = max((current - stamp).days, 0) if days <= 90: return 1.25 if days <= 365: return 1.0 if days <= 1100: return 0.75 return 0.55 def recency_role(occurred_at: str | None, now: datetime | None = None) -> str: stamp = parse_when(occurred_at) if not stamp: return "undated" current = now or datetime.now(timezone.utc) days = max((current - stamp).days, 0) if days <= 365: return "current_expression" return "long_term" def is_meta_style_text(text: str | None) -> bool: raw = (text or "").strip().lower() if not raw: return True if len(raw.split()) <= 24 and any(marker in raw for marker in META_STYLE_MARKERS): return True return False def normalize_evidence_basis(raw) -> list[str]: values = raw if isinstance(raw, list) else [raw] if raw else [] out: list[str] = [] seen: set[str] = set() for item in values: key = str(item or "").strip().lower().replace("-", "_").replace(" ", "_") if not key: continue mapped = BASIS_ALIASES.get(key) if not mapped: mapped = "kansho_sources" if key.startswith("kansho") else "external_context" if mapped in seen: continue seen.add(mapped) out.append(mapped) return out def is_external_only(basis) -> bool: values = list(basis or []) return bool(values) and "kansho_sources" not in values def normalize_mode(value: str | None) -> str: raw = (value or "").strip() if raw in {"incremental", "review"}: return "review" if raw == "initial_build": return "initial_build" return "" def seed_catalog() -> list[dict]: return [ { "kind": "facet", "key": key, "layer": item["layer"], "label": item["label"], "role": "ordering_help", } for key, item in SEED_FACETS.items() ] + [ { "kind": "trait_hint", "slug": item["slug"], "label": item["label"], "role": "ordering_help", } for item in SEED_TRAIT_HINTS ]