Kansho/backend/writing_profile_schema.py
2026-08-25 17:16:35 +02:00

244 lines
7.2 KiB
Python

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