244 lines
7.2 KiB
Python
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
|
|
]
|