This commit is contained in:
Lars 2026-08-25 13:57:23 +02:00
parent a8caeb0dd4
commit fd7102e3b3
95 changed files with 16520 additions and 193 deletions

View File

@ -8,7 +8,7 @@ Kanshō ist **kein** Tagebuch mit KI, keine Meditations-App und kein generischer
## Aktueller Stand
Fachliche Konzeption und technische Rahmenarchitektur plus ein **lokales Produktrahmen-Gerüst** (`frontend/`, `backend/`). Das Gerüst ist Auth, PWA-Shell und Privacy-Gateway-Stub noch kein Reflexions-MVP.
Fachliche Konzeption, technische Rahmenarchitektur und der erste **vertikale MVP-Slice** (dialoggeführtes Journal) auf dem lokalen Produktrahmen (`frontend/`, `backend/`). Identity bleibt lokal; persönlicher LLM-Egress nur über das Privacy Gateway.
| Bereich | Stand |
|---|---|
@ -16,7 +16,7 @@ Fachliche Konzeption und technische Rahmenarchitektur plus ein **lokales Produkt
| Technische Form | entschieden: PWA, Mobile First, Offline, Transkription |
| Fachliche Doku | Baseline in `docs/architecture/functional/` |
| Technische Doku | vorläufiger Rahmen in `docs/architecture/technical/` |
| App | lokaler Produktrahmen, Fachkonzept nicht eingefroren |
| App | MVP-Journal-Slice auf dem Produktrahmen; Dialog-IA und Target-Modell nicht eingefroren |
Remote: [gitea.stommer.de/Lars/Kansho](https://gitea.stommer.de/Lars/Kansho.git)
@ -58,8 +58,17 @@ Kanshō nutzt eigene lokale Ports, nicht die Vite-/FastAPI-Defaults: Frontend **
```powershell
cd backend
.\.venv\Scripts\python tests\test_frame.py
.\.venv\Scripts\python tests\test_dialogue_memory.py
.\.venv\Scripts\python tests\test_mvp_journal.py
.\.venv\Scripts\python tests\test_privacy_detect.py
```
Zwei Provider. URL, Modell und ZDR/No-Train unter **Admin → Schnittstellen**. Der Secret-Key nur in `backend/.env` (Vorlage: `backend/.env.example`, wird nicht committet) oder ebenfalls dort eintragen, nie in der Datenbank.
Das Sprachmodell (Dialogzug und Journalentwurf) bleibt fail-closed ohne Key plus ZDR und Trainingsverbot. Maskierung ist lokal per Muster; ein Detect-LLM ist optional und sieht Klartext, deshalb klein oder später Ollama.
Tests: `KANSHO_FAKE_PROVIDER=1`, optional `KANSHO_FAKE_DETECT=1`. Zusätzlich `tests/test_privacy_detect.py`.
## Lokal weiterarbeiten
```powershell

24
backend/.env.example Normal file
View File

@ -0,0 +1,24 @@
# Secrets only. URL, Modell und ZDR/No-Train: Admin → Schnittstellen.
# Env überschreibt die Admin-Werte, wenn gesetzt.
# development | test | production
# production: kein externes Klartext-Detect (Muster oder lokale Detect-URL).
# KANSHO_ENV=development
# Generate: nur maskierter Kontext. Extern: Key Pflicht.
KANSHO_PROVIDER_KEY=
# KANSHO_PROVIDER_URL=https://openrouter.ai/api/v1/chat/completions
# KANSHO_PROVIDER_MODEL=openai/gpt-4o-mini
# KANSHO_PROVIDER_ZDR=1
# KANSHO_PROVIDER_NO_TRAIN=1
# Detect: Klartext nur lokal oder in Development/Test. Leer = Generate-Key mitnutzen (nur Dev/Test).
KANSHO_DETECT_PROVIDER_KEY=
# KANSHO_DETECT_PROVIDER_URL=https://openrouter.ai/api/v1/chat/completions
# KANSHO_DETECT_PROVIDER_MODEL=openai/gpt-4.1-nano
# KANSHO_DETECT_ZDR=1
# KANSHO_DETECT_NO_TRAIN=1
# Nur Tests
# KANSHO_FAKE_PROVIDER=1
# KANSHO_FAKE_DETECT=1

View File

@ -1 +1,42 @@
[]
[
{
"id": "mvp-dialogue-turn",
"slug": "mvp.dialogue_turn",
"name": "MVP Dialogzug",
"description": "Ein maskierter Call: Operation und Impuls zusammen. Keine Maskierung, kein Journalentwurf, keine Intent-Engines.",
"category": "mvp",
"prompt_type": "base",
"required_feature": "ai_calls",
"template": "Du sitzt mit [[SELF]] im Reflection Space {{space_title}} und bist Kanshō: jemand, der zugehört hat. Warm, genau, auf Augenhöhe. Ein Mensch würde dich für einen Vertrauten halten, nicht für ein Programm.\n\n{{register_hint}}\n\n{{interaction_hint}}\n\nDie gesprochene Zeile (impulse) klingt wie mündliches Deutsch in einem Gespräch: flüssig, nicht steif, nicht literarisch, nicht wie eine App. Kurze Sätze sind in Ordnung. Kein „also“-Referat, kein „interessant“, kein „danke, dass du das teilst“, kein „lass uns“, kein „zusammengefasst“, keine Operationsnamen, kein JSON, keine Registerworte.\n\nNur user:-Zeilen sind Erlebtes. assistant:-Zeilen sind frühere Impulse, keine Tatsachen.\nErfinde nichts: keinen nächsten Ort, kein Gefühl, keine Motivation. Keine Tatsachen, Gefühle oder Motivationen erfinden. Ein Plan ist kein Vollzug. Nicht erwähnt ist nicht geschehen.\n\nLänge und Zahl der Anschlussimpulse stehen in der Interaction-Angabe oben, nicht als anonyme Produktregel. Leite einen echten Gedanken oder eine knappe Frage aus dem Gerade-Gesagten ab. Zusammenfassung des Zugs ist nicht der Default.\nNacherzählen nur, um einen Widerspruch oder Logikbruch zu klären.\n\nIntern eine Operation wählen, sie nicht aussprechen:\n- fortfuehren: am offenen Faden bleiben; bei Schluss den Schluss halten\n- konkretisieren: eine wirklich fehlende, tragende Angabe\n- plan_aufgreifen: ein genanntes Vorhaben als Vorhaben\n- abweichung: eine selbst markierte Veränderung\n- erleben_vertiefen: nur wenn [[SELF]] Wahrnehmung oder Wirkung öffnet\n- bedeutung: einen erkennbaren Punkt, ohne Tiefe zu erzwingen\n\nBei geschlossener Szene: keine Frage, keinen Bogen. Nicht den nächsten Vollzug setzen.\n\nKontext:\n{{dialogue_context}}\n\nNur JSON:\n{\"operation\":\"fortfuehren|konkretisieren|plan_aufgreifen|abweichung|erleben_vertiefen|bedeutung\",\"impulse\":\"gesprochene Zeile wie ein Vertrauter\"}\n"
},
{
"id": "mvp-journal-generate",
"slug": "mvp.journal_generate",
"name": "MVP Journalentwurf",
"description": "Tagebuchtext aus den Worten der Person. Explizit ausgelöst. Nicht der Dialogzug.",
"category": "mvp",
"prompt_type": "base",
"required_feature": "ai_calls",
"template": "Schreibe einen Tagebucheintrag in der Ich-Form von [[SELF]].\n\nAus den user:-Zeilen den Inhalt nehmen; die Stimme aus dem Writing Profile, sobald dort finale Fassungen oder Importe stehen. Szenen, Wartezeiten und Erzählstränge zusammenführen, nicht den Dialog hintereinanderkopieren. Uhrzeiten und Reihenfolge aus dem Gesagten behalten. Keine Zeiten erfinden, nichts umstellen, das die Chronologie verfälscht.\n\nSchritte, alle nötig:\n1. Nur Erlebtes aus user:. assistant:-Zeilen sind Kanshōs Impulse, keine Tatsachen. Sie zeigen nur, worauf die nächste user-Zeile antwortet; nicht in den Eintrag schreiben.\n2. Absätze nach Phase, Ort oder Uhrzeit. Nicht jede user-Zeile ein eigener Absatz. Dieselbe Begebenheit bleibt ein Strang.\n3. Übergänge glätten. Wiederholungen und Rückfragen streichen. Aufeinanderfolgende Züge zur selben Szene verbinden.\n4. Person: das Token nur dort, wo [[SELF]] den Namen genannt hat — in einer Passage ein- bis zweimal. Danach sie/er/ihr wie im user-Text. Indirekte Rede nicht umbiegen: „sie sagte, dass sie …“, nicht den Namen in die Selbstaussage setzen.\n5. Writing Profile: finale Fassungen sind die Stimme (Satzlänge, Wortwahl, Detailgrad, Übergänge). Kurze oder stichwortartige user-Zeilen zu ganzen Sätzen und Absätzen in genau dieser Stimme ausformulieren. Keine neuen Fakten, Gefühle oder Zeiten. Fehlt das Profile, die user-Formulierungen verbinden und glätten.\n\nErste Zeile: kurze Überschrift. Danach der Eintrag.\nBehalten: konkrete Wörter, Uhrzeiten, Details und selbst genannte Eindrücke — in der Stimme des Writing Profile, nicht als Dialogkopie.\nVerboten: den Dialog 1:1 kopieren; Kanshō-Fragen als Erlebtes; nur Überschrift plus denselben Text; ein einziger Absatz für den ganzen Tag; erfundene Fakten, Gefühle oder Details; generischer KI-Stil; jeden Bezug auf eine Person durch den Namen ersetzen.\n\nWriting Profile:\n{{writing_profile}}\n\nBestehender Text, nur wenn ausdrücklich gewünscht:\n{{existing_text}}\n\nDialogquellen:\n{{dialogue_context}}\n\nSchreibe jetzt den Eintrag: erste Zeile Überschrift, danach mehrere Absätze. Inhalt aus user:, Stimme aus dem Writing Profile. Verbinde Szenen und Zeiten, kopiere die user-Zeilen nicht.\n"
},
{
"id": "mvp-entity-detect",
"slug": "mvp.entity_detect",
"name": "MVP Maskierung",
"description": "Nur schützenswerte Eigennamen. Keine Dialogoperation, kein Journalentwurf. Optional; Normalweg ist Mustererkennung.",
"category": "privacy",
"prompt_type": "base",
"required_feature": "ai_calls",
"template": "Aufgabe: Nur identifizierende Eigennamen. Lieber zu wenig als zu viel.\n\nAufnehmen:\n- Vorname oder Rufname einer Person (Sushi, Anna)\n- benannter Ort (Hamburg, Lošinj)\n- benannte Organisation\nNur das Namenswort, nicht den ganzen Satzteil. „meine Frau Sushi“ → Sushi. Dieselbe Person immer derselbe Name, immer dasselbe Token.\n\nNicht aufnehmen:\n- Pronomen: ich, sie, wir, mich\n- Rollen und Verwandtschaft allein: Frau, Herr, Partner\n- Wetter und Natur: Himmel, Luft, Regen\n- Dinge, Speisen und Räume: Tee, Balkon, Laden, Bad, Brot, Zwiebeln, Tomaten, Paprika, Haferflocken\n- Zeit: 6:00 Uhr, 11:00, heute, gestern\n- Aktivitäten: Lektion, Bootstour, Frühstück, Tagebuch, Omlett\n- Dialogmarken: user, assistant\n\nSchon gemappt, nicht erneut und nicht unter neuem Token: {{known_labels}}\n\nBeispiel A\nText: Heute um 6:00 Uhr Tee auf dem Balkon, dann japanische Lektion. Frau Sushi kam dazu. Bootstour erst um 11:00.\nJSON: {\"entities\":[{\"text\":\"Sushi\",\"token\":\"PERSON:01\"}]}\n\nBeispiel B\nText: Ich war mit Anna in Hamburg.\nJSON: {\"entities\":[{\"text\":\"Anna\",\"token\":\"PERSON:01\"},{\"text\":\"Hamburg\",\"token\":\"PLACE:CITY\"}]}\n\nBeispiel C\nText: Der Himmel war klar, die Luft warm, ich kochte Tee.\nJSON: {\"entities\":[]}\n\nAntworte nur mit JSON: {\"entities\":[{\"text\":\"Name\",\"token\":\"PERSON:01\"}]}\nToken: PERSON:01, PERSON:02, PLACE:CITY, ORG:01. Nie PERSON:KURZ. KURZ ist kein Token. Keine sprechenden Aliase.\n\nText:\n{{source_text}}\n"
},
{
"id": "mvp-profile-review",
"slug": "mvp.profile_review",
"name": "MVP Writing-Profile-Review",
"description": "Gebündelte semantische Review. Kein neues Profil, nur Änderungsvorschläge. Explizit ausgelöst, nicht nach jedem Dialog.",
"category": "mvp",
"prompt_type": "base",
"required_feature": "ai_calls",
"template": "Du prüfst das Writing Profile von [[SELF]] gegen Evidenz.\n\nKeine Diagnose, kein Persönlichkeitsmodell, keine erfundenen Stilmerkmale. Lokale Heuristiken sind Messhilfe, keine Traits. Keys wie chronology, humor, detail, rhythm sind nur Ordnungshilfe, kein geschlossenes Raster.\n\nHülle: Core, optionale context/output-Facets, darin dynamische semantische Traits, Evidence References, Representative Exemplars.\nExisting-before-New: 1) bestehenden Trait bestätigen, 2) präzisieren, 3) Scope ändern, 4) zusammenführen oder aufteilen, 5) erst dann einen neuen Trait anlegen.\nUrlaubstagebücher und autobiografische Journale belegen primär autobiographical_journal. Einen globalen Core nur so weit verallgemeinern, wie unterschiedliche Quellenarten das tragen. Neuere Texte wiegen stärker für die aktuelle Ausprägung; ältere Texte belegen Langzeitmerkmale.\nJeder Trait nennt evidence_ids und darf Originalzitate als Exemplare führen. Locked-Traits nicht ändern.\n\nAntworte ausschließlich mit einem JSON-Objekt gemäß expected_result im Paket. Actions: confirm, precisify, rescope, merge, split, create. Kein Fließtext davor oder danach.\n\nPaket:\n{{review_package}}\n"
}
]

View File

@ -0,0 +1,18 @@
[
{
"role": "generate",
"name": "openrouter",
"url": "https://openrouter.ai/api/v1/chat/completions",
"model": "openai/gpt-4o-mini",
"zdr": true,
"no_train": true
},
{
"role": "detect",
"name": "openrouter",
"url": "https://openrouter.ai/api/v1/chat/completions",
"model": "openai/gpt-4.1-nano",
"zdr": true,
"no_train": true
}
]

View File

@ -1,14 +1,232 @@
"""Internal context for later Gateway use. Never sends originals to a provider."""
"""Internal context for later Gateway use. Never sends originals to a provider.
Selection goes through retrieval specs. Recency/SQL limits live in retrieval.py.
"""
from __future__ import annotations
import re
from data_layer import read
from dialogue_store import get_conversation
from retrieval import retrieve
from journal_body import plain_text
PURPOSES = {"dialogue_turn", "journal_generate"}
CLOSURE = re.compile(
r"\bauf den heimweg\b|"
r"\bheimweg\b|"
r"\bauf den rückweg\b|"
r"\bdamit (?:war|ist) der tag\b|"
r"\bdas war (?:der|so ein|unser) tag\b|"
r"\bende des tages\b|"
r"\bgenug für heute\b|"
r"\bso endete\b|"
r"müde.{0,80}(?:heimweg|rückweg)|"
r"(?:eindr[uü]cke|erschöpft).{0,80}(?:heimweg|rückweg)|"
r"machten uns.{0,40}auf den (?:heimweg|rückweg)|"
r"\bbald ins bett\b|"
r"\blegten uns.{0,40}ins bett\b|"
r"\bgleich ins bett\b",
re.I,
)
def build_internal_context(profile_id: str, conversation_id: str) -> dict:
messages = read("conversation_messages", profile_id=profile_id, context={"conversation_id": conversation_id})
def is_closing_turn(text: str) -> bool:
return bool(CLOSURE.search((text or "").strip()))
def infer_register(user_bodies: list[str]) -> str:
last = (user_bodies[-1] if user_bodies else "").strip()
if not last:
return "Noch kein Erzählstand. Warte auf den ersten Satz, setze keine Szene."
if is_closing_turn(last):
return (
"Die Szene oder der Abschnitt ist zu Ende. Halte den Schluss. "
"Keine Frage, keinen Bogen, keine neue Station."
)
words = last.split()
emotion = re.search(r"aufgeregt|traurig|berührt|ängst|wütend|freude|ruhig|unsicher", last, re.I)
plan = re.search(r"geplant|wollen|werde|sollten|vorhaben", last, re.I)
dense = len(words) >= 40 or last.count(".") + last.count("!") >= 3
if dense:
return (
"[[SELF]] erzählt gerade ausführlich. Am letzten erzählten Punkt bleiben. "
"Nicht nacherzählen. Nacherzählen nur bei Widerspruch oder Logikbruch. "
"Keine erfundene Empfindung, keine erfundene nächste Szene."
)
if emotion:
return (
"[[SELF]] hat Erleben geöffnet. Bei Bedeutung oder Wirkung bleiben, "
"die schon benannte Empfindung nicht nachplappern."
)
if plan:
return "Ein Vorhaben ist genannt. Als Plan behandeln, nicht als vollzogen."
return "Kurz und natürlich antworten, am offenen Punkt, wie jemand der zugehört hat."
def build_internal_context(
profile_id: str,
conversation_id: str | None = None,
space_id: str | None = None,
journal_day_id: str | None = None,
purpose: str = "dialogue_turn",
include_existing: bool = False,
conversation_ids: list[str] | None = None,
existing_text: str = "",
) -> dict:
purpose = purpose if purpose in PURPOSES else "dialogue_turn"
if conversation_id and not space_id:
conv = get_conversation(profile_id, conversation_id)
space_id = conv.get("space_id")
journal_day_id = journal_day_id or conv.get("journal_day_id")
items: list[dict] = []
space_title = ""
if space_id:
from dialogue_store import get_space
try:
space_title = get_space(profile_id, space_id).get("title") or ""
except Exception:
space_title = ""
items.append({"type": "space", "space_id": space_id, "title": space_title})
selected_ids = list(conversation_ids or [])
if conversation_id and conversation_id not in selected_ids and purpose == "dialogue_turn":
selected_ids.append(conversation_id)
day_messages = []
if journal_day_id:
day_messages = retrieve(
profile_id,
{
"kind": "day_messages",
"journal_day_id": journal_day_id,
"conversation_ids": selected_ids or None,
},
)
items.append({"type": "day_messages", "messages": day_messages})
elif conversation_id:
day_messages = read("conversation_messages", profile_id=profile_id, context={"conversation_id": conversation_id})
items.append({"type": "day_messages", "messages": day_messages})
if purpose == "dialogue_turn" and space_id:
prior = retrieve(
profile_id,
{
"kind": "space_entries",
"space_id": space_id,
"exclude_day_id": journal_day_id,
},
)
items.append(
{
"type": "prior_entries",
"entries": [
{
"entry_id": item["entry_id"],
"calendar_date": item.get("calendar_date"),
"excerpt": item.get("excerpt") or plain_text(item.get("body") or ""),
}
for item in prior
],
}
)
recent_sources = retrieve(
profile_id,
{
"kind": "space_recent_sources",
"space_id": space_id,
"exclude_conversation_id": conversation_id,
"exclude_journal_day_id": journal_day_id,
},
)
items.append({"type": "space_recent_sources", "sources": recent_sources})
if purpose == "journal_generate":
profile_rows = retrieve(profile_id, {"kind": "writing_profile"})
brief = (profile_rows[0].get("compiled_brief") if profile_rows else "") or ""
items.append({"type": "writing_profile", "compiled_brief": brief})
sources = (profile_rows[0].get("sources") if profile_rows else []) or []
style_sources = [
item
for item in sources
if item.get("kind") in {"journal_entry", "imported_text", "dialogue_style"}
]
items.append({"type": "style_sources", "sources": style_sources})
if include_existing and existing_text:
items.append({"type": "existing_text", "body": plain_text(existing_text)})
elif purpose == "dialogue_turn":
items.append({"type": "interaction_hint", "text": ""})
return {
"profile_id": profile_id,
"conversation_id": conversation_id,
"messages": messages,
"space_id": space_id,
"space_title": space_title,
"journal_day_id": journal_day_id,
"purpose": purpose,
"items": items,
"messages": day_messages,
"egress": False,
"note": "Volltext bleibt lokal. Minimierung und Egress nur über das Privacy Gateway.",
}
def assemble_text(context: dict) -> dict[str, str]:
dialogue_parts: list[str] = []
prior_parts: list[str] = []
source_parts: list[str] = []
writing_profile = ""
interaction_hint = ""
existing_text = ""
user_bodies: list[str] = []
purpose = context.get("purpose") or ""
for item in context.get("items") or []:
kind = item.get("type")
if kind == "day_messages":
for message in item.get("messages") or []:
role = message.get("role") or "user"
body = message.get("body") or ""
dialogue_parts.append(f"{role}: {body}")
if role == "user":
user_bodies.append(body)
elif kind == "prior_entries":
for entry in item.get("entries") or []:
date = entry.get("calendar_date") or ""
excerpt = entry.get("excerpt") or ""
if excerpt:
prior_parts.append(f"{date}: {excerpt}")
elif kind == "space_recent_sources":
for source in item.get("sources") or []:
excerpt = source.get("excerpt") or ""
if excerpt:
source_parts.append(excerpt)
elif kind == "writing_profile":
writing_profile = item.get("compiled_brief") or ""
elif kind == "interaction_hint":
interaction_hint = item.get("text") or ""
elif kind == "existing_text":
existing_text = item.get("body") or ""
if purpose == "dialogue_turn":
from conversation_signals import infer_signals
from interaction_profile_store import assemble_interaction_hint
interaction_hint = assemble_interaction_hint(
context.get("profile_id"),
infer_signals(user_bodies),
)
if prior_parts:
dialogue_parts.append("Frühere Einträge im Space (nur Hinweise, keine Gewissheit):")
dialogue_parts.extend(prior_parts)
if source_parts and purpose == "dialogue_turn":
dialogue_parts.append("Frühere Originalgespräche im Space (Ausschnitt, keine Gewissheit):")
dialogue_parts.extend(source_parts)
return {
"dialogue_context": "\n".join(dialogue_parts).strip(),
"writing_profile": writing_profile,
"interaction_hint": interaction_hint,
"existing_text": existing_text,
"space_title": context.get("space_title") or "",
"register_hint": infer_register(user_bodies),
}

View File

@ -0,0 +1,110 @@
"""Local operational signals for a conversation. No extra LLM call, not a Writing Profile."""
from __future__ import annotations
import re
EMOTION = re.compile(
r"aufgeregt|traurig|berührt|ängst|wütend|freude|ruhig|unsicher|gefühl|tiefer",
re.I,
)
PLAN = re.compile(r"geplant|wollen|werde|sollten|vorhaben", re.I)
CHRONICLE = re.compile(
r"\b(heute|gestern|morgens|mittags|abends|danach|vorher|später|zuerst|einkauf|"
r"spaziergang|markt|chronik)\b",
re.I,
)
CLOCK = re.compile(r"\b\d{1,2}:\d{2}\b|\b\d{1,2}\s*Uhr\b", re.I)
STOP = {
"dann", "noch", "schon", "wieder", "gegen", "waren", "wurde", "haben", "hatte",
"unter", "über", "nach", "beim", "eine", "einem", "einer", "dieser", "dieses",
"auch", "aber", "dass", "wenn", "sich", "uns", "euch", "mein", "dein", "sein",
"heute", "dann", "nach", "noch",
}
DEEP_OPS = {"erleben_vertiefen", "bedeutung"}
PLAN_OPS = {"plan_aufgreifen"}
def _words(text: str) -> list[str]:
return [
word
for word in re.findall(r"[a-zäöüß]{4,}", (text or "").lower())
if word not in STOP
]
def infer_signals(user_bodies: list[str], last_operation: str | None = None) -> dict:
"""Derive operational dialogue state. Not a Writing Profile or Interaction Preference."""
bodies = [item.strip() for item in user_bodies if (item or "").strip()]
blob = " ".join(bodies)
last = bodies[-1] if bodies else ""
op = (last_operation or "").strip().lower()
emotion = bool(EMOTION.search(blob))
plan = bool(PLAN.search(blob))
chronicle = bool(CHRONICLE.search(blob) or CLOCK.search(blob))
if emotion:
mode, depth = "reflective", "deep"
elif plan:
mode, depth = "plan", "surface"
elif chronicle:
mode, depth = "chronicle", "surface"
elif op in DEEP_OPS:
mode, depth = "reflective", "deep"
elif op in PLAN_OPS:
mode, depth = "plan", "surface"
else:
mode, depth = "open", "surface"
counts: dict[str, int] = {}
for word in _words(blob):
counts[word] = counts.get(word, 0) + 1
focus = " ".join(
item[0] for item in sorted(counts.items(), key=lambda pair: (-pair[1], pair[0]))[:8]
)
last_words = last.split()
long_story = len(last_words) >= 40 or last.count(".") + last.count("!") >= 3
if emotion and (long_story or depth == "deep"):
intensity = "high"
elif emotion:
intensity = "medium"
else:
intensity = "low"
return {
"narrative_mode": mode,
"reflection_depth": depth,
"current_focus": focus,
"emotional_intensity": intensity,
"long_story": 1 if long_story else 0,
}
def _focus_words(row: dict) -> set[str]:
return {item for item in str(row.get("current_focus") or "").split() if item}
def compatible(left: dict, right: dict) -> bool:
left_mode = (left.get("narrative_mode") or "open").strip() or "open"
right_mode = (right.get("narrative_mode") or "open").strip() or "open"
if left_mode == "open" or right_mode == "open":
return False
if left_mode != right_mode:
return False
left_depth = (left.get("reflection_depth") or "surface").strip() or "surface"
right_depth = (right.get("reflection_depth") or "surface").strip() or "surface"
if left_depth != right_depth:
return False
left_focus = _focus_words(left)
right_focus = _focus_words(right)
if len(left_focus) >= 2 and len(right_focus) >= 2 and not (left_focus & right_focus):
return False
return True
def similar_enough(conversations: list[dict]) -> bool:
usable = [item for item in conversations if item]
if len(usable) < 2:
return False
for index, left in enumerate(usable):
for right in usable[index + 1 :]:
if not compatible(left, right):
return False
return True

View File

@ -30,6 +30,38 @@ _PROMPT_COLUMNS = {
"sort_order": "INTEGER NOT NULL DEFAULT 0",
"updated": "TEXT NOT NULL DEFAULT (datetime('now'))",
}
_CONVERSATION_COLUMNS = {
"space_id": "TEXT",
"journal_day_id": "TEXT",
"narrative_mode": "TEXT NOT NULL DEFAULT ''",
"reflection_depth": "TEXT NOT NULL DEFAULT ''",
"current_focus": "TEXT NOT NULL DEFAULT ''",
"emotional_intensity": "TEXT NOT NULL DEFAULT ''",
"long_story": "INTEGER NOT NULL DEFAULT 0",
}
_WRITING_PROFILE_COLUMNS = {
"governance": "TEXT NOT NULL DEFAULT 'learning'",
"version": "INTEGER NOT NULL DEFAULT 0",
"review_ready": "INTEGER NOT NULL DEFAULT 0",
"last_reviewed": "TEXT",
"lifecycle": "TEXT NOT NULL DEFAULT 'uninitialized'",
}
_WRITING_SOURCE_COLUMNS = {
"occurred_at": "TEXT",
"context_hint": "TEXT NOT NULL DEFAULT ''",
}
_WRITING_SUGGESTION_COLUMNS = {
"trait_slug": "TEXT NOT NULL DEFAULT ''",
"action": "TEXT NOT NULL DEFAULT 'update'",
"payload_json": "TEXT NOT NULL DEFAULT '{}'",
}
_WRITING_VERSION_COLUMNS = {
"lifecycle": "TEXT NOT NULL DEFAULT ''",
"traits_json": "TEXT NOT NULL DEFAULT '[]'",
}
_JOURNAL_DAY_COLUMNS = {
"scratch_json": "TEXT NOT NULL DEFAULT '[]'",
}
def _connect() -> sqlite3.Connection:
@ -119,35 +151,303 @@ def _seed_prompts(conn: sqlite3.Connection) -> None:
"""Prompts come from JSON/DB, never from Python string literals."""
items = json.loads(PROMPTS_SEED_PATH.read_text(encoding="utf-8"))
for item in items:
template = item.get("template") or ""
existing = row_to_dict(
conn.execute("SELECT * FROM ai_prompts WHERE slug = ?", (item["slug"],)).fetchone()
)
if existing is None:
conn.execute(
"""
INSERT INTO ai_prompts
(id, slug, name, description, category, prompt_type, template,
required_feature, is_system_default, default_template)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
""",
(
item["id"],
item["slug"],
item["name"],
item.get("description") or "",
item.get("category") or "uncategorized",
item.get("prompt_type") or "base",
template,
item.get("required_feature") or "ai_calls",
template,
),
)
continue
untouched = (existing.get("template") or "") == (existing.get("default_template") or "")
conn.execute(
"""
INSERT OR IGNORE INTO ai_prompts
(id, slug, name, description, category, prompt_type, template,
required_feature, is_system_default, default_template)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
UPDATE ai_prompts
SET name = ?, description = ?, category = ?, prompt_type = ?,
required_feature = ?, is_system_default = 1, default_template = ?,
template = CASE WHEN ? THEN ? ELSE template END,
updated = datetime('now')
WHERE slug = ?
""",
(
item["id"],
item["slug"],
item["name"],
item.get("description") or "",
item.get("category") or "uncategorized",
item.get("prompt_type") or "base",
item.get("template") or "",
item.get("required_feature") or "ai_calls",
item.get("template") or "",
template,
1 if untouched else 0,
template,
item["slug"],
),
)
def _parse_legacy_ids(raw: str | None) -> list[str]:
if not raw:
return []
try:
data = json.loads(raw)
except json.JSONDecodeError:
return []
if not isinstance(data, list):
return []
return [str(item) for item in data if item]
def _insert_source_refs(
conn: sqlite3.Connection,
table: str,
owner_col: str,
owner_id: str,
profile_id: str,
conversation_ids: list[str],
message_ids: list[str],
) -> None:
conn.execute(f"DELETE FROM {table} WHERE {owner_col} = ?", (owner_id,))
for index, source_id in enumerate(conversation_ids):
conn.execute(
f"""
INSERT OR IGNORE INTO {table}
({owner_col}, profile_id, source_kind, source_id, sort_order)
VALUES (?, ?, 'conversation', ?, ?)
""",
(owner_id, profile_id, source_id, index),
)
for index, source_id in enumerate(message_ids):
conn.execute(
f"""
INSERT OR IGNORE INTO {table}
({owner_col}, profile_id, source_kind, source_id, sort_order)
VALUES (?, ?, 'message', ?, ?)
""",
(owner_id, profile_id, source_id, index),
)
def migrate_journal_source_refs(conn: sqlite3.Connection) -> None:
"""Copy JSON id lists into relational source-ref tables without touching Source."""
existing = {
row["name"]
for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table'"
).fetchall()
}
if "journal_draft_source_refs" not in existing or "journal_entry_version_source_refs" not in existing:
return
for row in conn.execute(
"SELECT id, profile_id, source_conversation_ids, source_message_ids FROM journal_drafts"
).fetchall():
already = conn.execute(
"SELECT 1 FROM journal_draft_source_refs WHERE draft_id = ? LIMIT 1",
(row["id"],),
).fetchone()
if already:
continue
_insert_source_refs(
conn,
"journal_draft_source_refs",
"draft_id",
row["id"],
row["profile_id"],
_parse_legacy_ids(row["source_conversation_ids"]),
_parse_legacy_ids(row["source_message_ids"]),
)
for row in conn.execute(
"SELECT id, profile_id, source_conversation_ids, source_message_ids FROM journal_entry_versions"
).fetchall():
already = conn.execute(
"SELECT 1 FROM journal_entry_version_source_refs WHERE version_id = ? LIMIT 1",
(row["id"],),
).fetchone()
if already:
continue
_insert_source_refs(
conn,
"journal_entry_version_source_refs",
"version_id",
row["id"],
row["profile_id"],
_parse_legacy_ids(row["source_conversation_ids"]),
_parse_legacy_ids(row["source_message_ids"]),
)
def _migrate_writing_profile_dialogue_style(conn: sqlite3.Connection) -> None:
"""Existing DBs keep the old CHECK; recreate so dialogue_style is a valid source kind."""
row = row_to_dict(
conn.execute(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'writing_profile_sources'"
).fetchone()
)
current = (row or {}).get("sql") or ""
if "dialogue_style" in current:
return
conn.executescript(
"""
CREATE TABLE writing_profile_sources_new (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('imported_text', 'journal_entry', 'dialogue_style')),
body TEXT NOT NULL DEFAULT '',
entry_id TEXT REFERENCES journal_entries(id),
weight REAL NOT NULL DEFAULT 1,
created TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO writing_profile_sources_new
(id, profile_id, kind, body, entry_id, weight, created)
SELECT id, profile_id, kind, body, entry_id, weight, created
FROM writing_profile_sources;
DROP TABLE writing_profile_sources;
ALTER TABLE writing_profile_sources_new RENAME TO writing_profile_sources;
"""
)
def _migrate_writing_profile_shell(conn: sqlite3.Connection) -> None:
"""Layers stay; inferred count-facets are not the profile. Manual style keys become traits."""
from writing_profile_schema import LEGACY_STYLE_KEYS, coerce_slug, facet_layer, normalize_facet_key
facet_sql = row_to_dict(
conn.execute(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'writing_profile_facets'"
).fetchone()
)
current = (facet_sql or {}).get("sql") or ""
if current and ("inferred" in current or "layer" not in current):
conn.executescript(
"""
CREATE TABLE writing_profile_facets_new (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
facet_key TEXT NOT NULL,
layer TEXT NOT NULL DEFAULT 'context',
value TEXT NOT NULL DEFAULT '',
evidence TEXT NOT NULL DEFAULT '',
origin TEXT NOT NULL DEFAULT 'manual',
locked INTEGER NOT NULL DEFAULT 0,
updated TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (profile_id, facet_key)
);
INSERT INTO writing_profile_facets_new
(id, profile_id, facet_key, layer, value, evidence, origin, locked, updated)
SELECT id, profile_id, facet_key, 'context', value, evidence,
CASE WHEN origin IN ('manual', 'imported', 'accepted_suggestion', 'initial_build')
THEN origin ELSE 'manual' END,
locked, updated
FROM writing_profile_facets
WHERE origin IS NOT NULL AND origin != 'inferred';
DROP TABLE writing_profile_facets;
ALTER TABLE writing_profile_facets_new RENAME TO writing_profile_facets;
"""
)
names = _column_names(conn, "writing_profile_facets")
if "layer" not in names:
conn.execute("ALTER TABLE writing_profile_facets ADD COLUMN layer TEXT NOT NULL DEFAULT 'context'")
rows = [
dict(item)
for item in conn.execute("SELECT * FROM writing_profile_facets").fetchall()
]
for item in rows:
key = normalize_facet_key(item.get("facet_key") or "")
origin = item.get("origin") or "manual"
if (item.get("facet_key") or "") in LEGACY_STYLE_KEYS:
slug = coerce_slug(item.get("facet_key") or "trait")
conn.execute(
"""
INSERT OR IGNORE INTO writing_profile_traits
(id, profile_id, facet_key, slug, label, statement, origin, locked, status)
VALUES (?, ?, 'core', ?, ?, ?, ?, ?, 'active')
""",
(
item["id"],
item["profile_id"],
slug,
item.get("facet_key") or slug,
item.get("value") or "",
origin if origin in {"manual", "imported", "accepted_suggestion", "initial_build"} else "manual",
int(item.get("locked") or 0),
),
)
conn.execute("DELETE FROM writing_profile_facets WHERE id = ?", (item["id"],))
continue
if key != (item.get("facet_key") or ""):
conn.execute(
"UPDATE writing_profile_facets SET facet_key = ? WHERE id = ?",
(key, item["id"]),
)
layer = facet_layer(key)
conn.execute("UPDATE writing_profile_facets SET layer = ? WHERE id = ?", (layer, item["id"]))
for row in conn.execute("SELECT profile_id FROM writing_profiles").fetchall():
profile_id = row["profile_id"]
remaining = conn.execute(
"SELECT 1 FROM writing_profile_facets WHERE profile_id = ? LIMIT 1",
(profile_id,),
).fetchone()
traits = conn.execute(
"SELECT 1 FROM writing_profile_traits WHERE profile_id = ? AND status = 'active' LIMIT 1",
(profile_id,),
).fetchone()
if remaining or traits:
conn.execute(
"""
UPDATE writing_profiles
SET lifecycle = 'confirmed'
WHERE profile_id = ? AND lifecycle = 'uninitialized'
""",
(profile_id,),
)
def init_db() -> None:
schema = SCHEMA_PATH.read_text(encoding="utf-8")
with get_db() as conn:
conn.executescript(schema)
_seed_platform(conn)
_seed_prompts(conn)
from provider_settings import seed_provider_settings
seed_provider_settings(conn)
_ensure_columns(conn, "profiles", _PROFILE_COLUMNS)
_ensure_columns(conn, "ai_prompts", _PROMPT_COLUMNS)
_ensure_columns(conn, "conversations", _CONVERSATION_COLUMNS)
_ensure_columns(conn, "journal_days", _JOURNAL_DAY_COLUMNS)
_ensure_columns(conn, "writing_profiles", _WRITING_PROFILE_COLUMNS)
_migrate_writing_profile_dialogue_style(conn)
_ensure_columns(conn, "writing_profile_sources", _WRITING_SOURCE_COLUMNS)
_ensure_columns(conn, "writing_profile_suggestions", _WRITING_SUGGESTION_COLUMNS)
_ensure_columns(conn, "writing_profile_versions", _WRITING_VERSION_COLUMNS)
migrate_journal_source_refs(conn)
_migrate_writing_profile_shell(conn)
_mark(conn, "001_frame")
_mark(conn, "002_platform")
_mark(conn, "003_dialogue_memory")
_mark(conn, "004_mvp_journal")
_mark(conn, "005_provider_settings")
_mark(conn, "006_writing_profile_dialogue_style")
_mark(conn, "007_journal_day_scratch")
_mark(conn, "008_journal_source_refs")
_mark(conn, "009_conversation_signals")
_mark(conn, "010_profile_governance")
_mark(conn, "011_profile_review")
_mark(conn, "012_profile_shell")
from writing_profile_store import bootstrap_from_existing
bootstrap_from_existing()

View File

@ -81,19 +81,36 @@ def thread_snapshot(profile_id: str) -> list[dict]:
return [row_to_dict(row) for row in rows]
def create_conversation(profile_id: str, usage_session_id: str | None = None, title: str = "") -> dict:
def create_conversation(
profile_id: str,
usage_session_id: str | None = None,
title: str = "",
space_id: str | None = None,
journal_day_id: str | None = None,
) -> dict:
conversation_id = str(uuid.uuid4())
with get_db() as conn:
if usage_session_id:
session = _owned(conn, "usage_sessions", usage_session_id, profile_id)
if not session:
raise StoreError("not_found", "Nutzungssitzung nicht gefunden", 404)
if space_id and not _owned(conn, "spaces", space_id, profile_id):
raise StoreError("not_found", "Reflection Space nicht gefunden", 404)
if journal_day_id:
day = _owned(conn, "journal_days", journal_day_id, profile_id)
if not day:
raise StoreError("not_found", "Journal Day nicht gefunden", 404)
if space_id and day["space_id"] != space_id:
raise StoreError("assignment_mismatch", "Journal Day gehört zu einem anderen Space")
space_id = space_id or day["space_id"]
conn.execute(
"""
INSERT INTO conversations (id, profile_id, usage_session_id, title)
VALUES (?, ?, ?, ?)
INSERT INTO conversations
(id, profile_id, usage_session_id, title, space_id, journal_day_id,
narrative_mode, reflection_depth, current_focus)
VALUES (?, ?, ?, ?, ?, ?, '', '', '')
""",
(conversation_id, profile_id, usage_session_id, title or ""),
(conversation_id, profile_id, usage_session_id, title or "", space_id, journal_day_id),
)
return row_to_dict(conn.execute("SELECT * FROM conversations WHERE id = ?", (conversation_id,)).fetchone())
@ -106,6 +123,29 @@ def get_conversation(profile_id: str, conversation_id: str) -> dict:
return conv
def update_conversation_signals(profile_id: str, conversation_id: str, signals: dict) -> dict:
get_conversation(profile_id, conversation_id)
with get_db() as conn:
conn.execute(
"""
UPDATE conversations
SET narrative_mode = ?, reflection_depth = ?, current_focus = ?,
emotional_intensity = ?, long_story = ?, updated = datetime('now')
WHERE id = ? AND profile_id = ?
""",
(
(signals.get("narrative_mode") or "")[:40],
(signals.get("reflection_depth") or "")[:40],
(signals.get("current_focus") or "")[:200],
(signals.get("emotional_intensity") or "")[:40],
1 if signals.get("long_story") else 0,
conversation_id,
profile_id,
),
)
return get_conversation(profile_id, conversation_id)
def list_conversations(profile_id: str) -> list[dict]:
with get_db() as conn:
rows = conn.execute(
@ -223,6 +263,89 @@ def create_space(profile_id: str, title: str = "", visibility: str = "internal")
return row_to_dict(conn.execute("SELECT * FROM spaces WHERE id = ?", (space_id,)).fetchone())
def get_space(profile_id: str, space_id: str) -> dict:
with get_db() as conn:
space = _owned(conn, "spaces", space_id, profile_id)
if not space:
raise StoreError("not_found", "Reflection Space nicht gefunden", 404)
return space
def list_spaces(profile_id: str, visibility: str | None = "user") -> list[dict]:
with get_db() as conn:
if visibility:
rows = conn.execute(
"""
SELECT * FROM spaces
WHERE profile_id = ? AND visibility = ?
ORDER BY updated DESC, created DESC
""",
(profile_id, visibility),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM spaces WHERE profile_id = ? ORDER BY updated DESC, created DESC",
(profile_id,),
).fetchall()
return [row_to_dict(row) for row in rows]
def rename_space(profile_id: str, space_id: str, title: str) -> dict:
if not (title or "").strip():
raise StoreError("empty_title", "Space-Name darf nicht leer sein")
with get_db() as conn:
if not _owned(conn, "spaces", space_id, profile_id):
raise StoreError("not_found", "Reflection Space nicht gefunden", 404)
conn.execute(
"UPDATE spaces SET title = ?, updated = datetime('now') WHERE id = ? AND profile_id = ?",
(title.strip(), space_id, profile_id),
)
return row_to_dict(
conn.execute("SELECT * FROM spaces WHERE id = ? AND profile_id = ?", (space_id, profile_id)).fetchone()
)
def delete_conversation(profile_id: str, conversation_id: str) -> dict:
with get_db() as conn:
conv = _owned(conn, "conversations", conversation_id, profile_id)
if not conv:
raise StoreError("not_found", "Conversation nicht gefunden", 404)
conn.execute(
"""
UPDATE handoffs SET source_conversation_id = NULL
WHERE profile_id = ? AND source_conversation_id = ?
""",
(profile_id, conversation_id),
)
conn.execute(
"""
DELETE FROM derived_records
WHERE profile_id = ? AND subject_type = 'conversation' AND subject_id = ?
""",
(profile_id, conversation_id),
)
conn.execute(
"DELETE FROM conversations WHERE id = ? AND profile_id = ?",
(conversation_id, profile_id),
)
return {"id": conversation_id, "deleted": True}
def list_conversations_for_day(profile_id: str, journal_day_id: str) -> list[dict]:
with get_db() as conn:
rows = conn.execute(
"""
SELECT c.*,
(SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id) AS message_count
FROM conversations c
WHERE c.profile_id = ? AND c.journal_day_id = ?
ORDER BY c.created
""",
(profile_id, journal_day_id),
).fetchall()
return [row_to_dict(row) for row in rows]
def link_thread_space(profile_id: str, thread_id: str, space_id: str, confidence: float | None = None) -> dict:
with get_db() as conn:
if not _owned(conn, "threads", thread_id, profile_id):

365
backend/dialogue_turn.py Normal file
View File

@ -0,0 +1,365 @@
"""One generative call per user turn. Source messages are persisted before and after the call."""
from __future__ import annotations
import json
import re
from context_builder import assemble_text, build_internal_context, is_closing_turn
from conversation_signals import infer_signals
from dialogue_store import append_message, get_conversation, list_messages, update_conversation_signals
from engine import EngineError, execute_prompt, load_active_prompt
from writing_profile_store import remember_dialogue_style
from profile_review import consider_dialogue
JSON_BLOCK = re.compile(r"\{.*\}", re.DOTALL)
PLOT_CONTINUATION = re.compile(
r"^\s*und\s+dann\b|"
r"ihr\s+seid\s+dann|"
r"seid\s+(?:ihr|du)\s+(?:dann\s+)?(?:zur|zum|in|auf|nach)|"
r"\b(?:aufgebrochen|losgegangen|losgefahren)\b",
re.IGNORECASE,
)
INTERVIEW_OPEN = re.compile(
r"^\s*(?:was|wie|warum|weshalb|wieso|erzähl(?:st|t)?|und\s+was)\b",
re.IGNORECASE,
)
DANN_PROBE = re.compile(r"\b(?:was|wie)\b.+\bdann\b|\bdann\b.+\b(?:gezeigt|geschehen|passiert)\b", re.IGNORECASE)
COMPLETION_ASK = re.compile(
r"^\s*ob\s+(?:ihr|du|wir)\b|"
r"\blosgekommen\b|"
r"\brechtzeitig\b.+\b(?:seid|habt|wart|gekommen)\b",
re.IGNORECASE,
)
FINITE_VERB = re.compile(
r"\b(?:ist|war|sind|waren|hat|hatte|wird|wurde|bleibt|liegt|steht|"
r"kam|ging|kann|muss|soll|will)\b",
re.IGNORECASE,
)
UNEARNED_COMPLETION = re.compile(
r"\b(?:holtet|geholt|kauftet|gekauft|aßet|gegessen|unternahmt|"
r"angekommen|aufgebrochen|losgegangen|losgefahren|losgekommen|weitergegangen)\b",
re.IGNORECASE,
)
NEXT_BEAT_Q = re.compile(
r"^\s*also\s+(?:seid ihr|habt ihr|bist du)\b|"
r"(?:seid ihr|habt ihr|bist du)\s+dann\b|"
r"(?:seid ihr|habt ihr|bist du).+\b(?:gesprungen|gerannt|rüber|geschafft|losgekommen)\b",
re.IGNORECASE,
)
MACHINE_TELL = re.compile(
r"\bals ki\b|"
r"ich bin (?:eine? )?(?:ki|sprachmodell|assistent)\b|"
r"danke,? dass du (?:das )?(?:teilst|erzählst)|"
r"lass uns (?:das )?(?:gemeinsam|mal)|"
r"\bzusammengefasst\b|"
r"ich höre (?:da )?heraus|"
r"^\s*interessant\b",
re.IGNORECASE,
)
UNEARNED_STANCE = re.compile(
r"fühlte\s+sich|an(?:ge)?fühlt|"
r"schmeckte|roch\b|klang\b|"
r"anders als(?:\s+erwartet|\s+gedacht|\s+geplant)?|"
r"sicher anders|"
r"\bmusste noch\b|\bmuss noch\b|"
r"für die nächste[n]?\b|"
r"gehörte uns\b",
re.IGNORECASE,
)
CONTENT_STOP = {
"dann", "noch", "schon", "wieder", "gegen",
"waren", "wurde", "haben", "hatte", "lagen", "bereit", "durch",
"unter", "über", "nach", "beim", "eine", "einem", "einer",
"dieser", "dieses", "auch", "aber", "dass", "wenn", "dann",
"sich", "uns", "euch", "mein", "dein", "sein", "ihre",
}
OPERATIONS = {
"fortfuehren": "Fortführen",
"konkretisieren": "Konkretisieren",
"plan_aufgreifen": "Plan oder Erwartung aufgreifen",
"abweichung": "Abweichung erkunden",
"erleben_vertiefen": "Erleben vertiefen",
"bedeutung": "Bedeutung erkunden",
}
def parse_turn_payload(content: str) -> tuple[str, dict]:
text = (content or "").strip()
blob = text
if blob.startswith("```"):
blob = re.sub(r"^```(?:json)?\s*|\s*```$", "", blob, flags=re.IGNORECASE | re.DOTALL)
match = JSON_BLOCK.search(blob)
if match:
try:
data = json.loads(match.group(0))
except json.JSONDecodeError:
data = None
if isinstance(data, dict):
operation = str(data.get("operation") or "").strip().lower()
impulse = str(data.get("impulse") or "").strip()
if impulse:
label = OPERATIONS.get(operation)
return impulse, {
"operation": operation if label else "unparsed",
"label": label or "nicht erkannt",
"parsed": bool(label),
}
return text, {"operation": "unparsed", "label": "nicht erkannt", "parsed": False}
def is_plot_continuation(impulse: str) -> bool:
text = (impulse or "").strip()
if not text:
return False
return bool(PLOT_CONTINUATION.search(text))
def is_interview_question(impulse: str) -> bool:
return bool(INTERVIEW_OPEN.search((impulse or "").strip()))
def is_unearned_completion(impulse: str) -> bool:
return bool(UNEARNED_COMPLETION.search(impulse or ""))
def is_unearned_stance(impulse: str) -> bool:
return bool(UNEARNED_STANCE.search(impulse or ""))
def is_machine_tell(impulse: str) -> bool:
return bool(MACHINE_TELL.search(impulse or ""))
def last_user_text(assembled: dict | None) -> str:
ctx = (assembled or {}).get("dialogue_context") or ""
lines = [line[5:].strip() for line in ctx.splitlines() if line.startswith("user:")]
return lines[-1] if lines else ""
def earlier_user_text(assembled: dict | None) -> str:
ctx = (assembled or {}).get("dialogue_context") or ""
lines = [line[5:].strip() for line in ctx.splitlines() if line.startswith("user:")]
return " ".join(lines[:-1])
def user_closed_day(assembled: dict | None) -> bool:
return is_closing_turn(last_user_text(assembled))
def is_day_arc_recap(impulse: str, assembled: dict | None) -> bool:
last = last_user_text(assembled)
earlier = earlier_user_text(assembled)
if not last or not earlier:
return False
last_words = set(content_words(last))
earlier_only = set(content_words(earlier)) - last_words
if len(earlier_only) < 6:
return False
foreign = {word for word in content_words(impulse) if word in earlier_only}
return len(foreign) >= 3
def is_reopen_after_close(impulse: str, assembled: dict | None) -> bool:
if not user_closed_day(assembled):
return False
if "?" in (impulse or ""):
return True
return is_day_arc_recap(impulse, assembled)
def content_words(text: str) -> list[str]:
return [
word
for word in re.findall(r"[a-zäöüß]{4,}", (text or "").lower())
if word not in CONTENT_STOP
]
def is_echo(impulse: str, source: str) -> bool:
src = set(content_words(source))
imp = content_words(impulse)
if len(imp) < 3 or len(src) < 3:
return False
hits = 0
for word in imp:
if word in src or any(len(item) >= 5 and word.startswith(item[:5]) for item in src):
hits += 1
return hits / len(imp) >= 0.5
def is_verbless_echo(impulse: str, source: str) -> bool:
text = (impulse or "").strip()
if not text or "?" in text or FINITE_VERB.search(text):
return False
src = set(content_words(source))
imp = content_words(text)
if not imp or not src:
return False
return all(
word in src or any(len(item) >= 5 and word.startswith(item[:5]) for item in src)
for word in imp
)
def is_dann_probe(impulse: str) -> bool:
return bool(DANN_PROBE.search(impulse or ""))
def is_completion_ask(impulse: str) -> bool:
return bool(COMPLETION_ASK.search(impulse or ""))
def last_user_is_narrative(assembled: dict | None) -> bool:
return len(last_user_text(assembled).split()) >= 15
def is_next_beat_question(impulse: str) -> bool:
return bool(NEXT_BEAT_Q.search(impulse or ""))
def is_recap_then_ask(impulse: str, assembled: dict | None = None) -> bool:
text = (impulse or "").strip()
if "?" not in text or not last_user_is_narrative(assembled):
return False
statement = text.split("?", 1)[0].strip()
if len(content_words(statement)) < 6:
return False
return is_echo(statement, last_user_text(assembled))
def is_pure_recap(impulse: str, assembled: dict | None = None) -> bool:
text = (impulse or "").strip()
if not text or "?" in text or not last_user_is_narrative(assembled):
return False
last = last_user_text(assembled)
return is_echo(text, last) or is_verbless_echo(text, last)
def local_hold(last_user: str) -> str:
if is_closing_turn(last_user):
return "Dann ist das der Schluss."
if len((last_user or "").split()) >= 15:
return "Erzähl bitte weiter."
return "Ich bin gespannt, wie es weitergeht."
def repair_note(impulse: str, assembled: dict | None) -> str:
if is_reopen_after_close(impulse, assembled):
return (
"Korrektur: Der Tag oder die Szene ist geschlossen. "
"Halte den Schluss. Keine Frage, keinen Tagesbogen."
)
if is_unearned_stance(impulse):
return (
"Korrektur: Keine erfundene Empfindung und keinen Vergleich mit ungenannten anderen Tagen. "
"Bleib bei dem, was [[SELF]] selbst gesagt hat."
)
if is_machine_tell(impulse):
return (
"Die gesprochene Zeile klingt wie ein Mensch im Gespräch, "
"nicht wie ein System oder eine Zusammenfassung."
)
if is_next_beat_question(impulse) or is_recap_then_ask(impulse, assembled) or is_pure_recap(impulse, assembled):
return (
"Korrektur: Nicht nacherzählen. Ein kurzer Impuls oder Denkanstoß am letzten Faden. "
"Nacherzählen nur, um einen Widerspruch oder Logikbruch zu klären."
)
return (
"Korrektur: Der vorige Impuls hat den nächsten Vollzug gesetzt "
"oder danach gefragt. Bleib Vertrauter. Kein Also-seid-ihr-dann. "
"Erzähle den Tag nicht weiter."
)
def needs_repair(impulse: str, assembled: dict | None = None) -> bool:
return (
is_plot_continuation(impulse)
or is_unearned_completion(impulse)
or is_unearned_stance(impulse)
or is_next_beat_question(impulse)
or is_recap_then_ask(impulse, assembled)
or is_pure_recap(impulse, assembled)
or is_machine_tell(impulse)
or is_reopen_after_close(impulse, assembled)
)
def visible_for_role(payload: dict, role: str | None) -> dict:
if role == "admin":
return payload
cleaned = dict(payload)
cleaned.pop("trace", None)
cleaned.pop("decision", None)
return cleaned
def run_turn(profile_id: str, conversation_id: str, body: str, message_id: str | None = None) -> dict:
conversation = get_conversation(profile_id, conversation_id)
user = append_message(profile_id, conversation_id, body, role="user", message_id=message_id)
context = build_internal_context(
profile_id,
conversation_id=conversation_id,
space_id=conversation.get("space_id"),
journal_day_id=conversation.get("journal_day_id"),
purpose="dialogue_turn",
)
assembled = assemble_text(context)
prompt = load_active_prompt("mvp.dialogue_turn")
calls = 0
result = None
impulse = ""
decision: dict = {"operation": "unparsed", "label": "nicht erkannt", "parsed": False}
try:
while calls < 2:
result = execute_prompt(
prompt,
profile_id,
purpose="dialogue_turn",
data_class="B",
context=assembled,
)
calls += 1
content = (result.get("content") or "").strip()
if not content:
raise EngineError("empty_provider_response", "Der Provider lieferte keine Antwort.")
impulse, decision = parse_turn_payload(content)
if not needs_repair(impulse, assembled):
break
decision = {**decision, "guard": "impulse_rejected"}
assembled = dict(assembled)
assembled["dialogue_context"] = (
(assembled.get("dialogue_context") or "") + "\n\n" + repair_note(impulse, assembled)
)
if needs_repair(impulse, assembled):
parsed_ok = bool(decision.get("parsed"))
impulse = local_hold(last_user_text(assembled))
decision = {
"operation": "fortfuehren",
"label": OPERATIONS["fortfuehren"],
"parsed": parsed_ok,
"guard": "local_fallback",
}
except EngineError:
raise
assistant = append_message(profile_id, conversation_id, impulse, role="assistant")
user_bodies = [
item.get("body") or ""
for item in list_messages(profile_id, conversation_id)
if item.get("role") == "user"
]
update_conversation_signals(
profile_id,
conversation_id,
infer_signals(user_bodies, decision.get("operation")),
)
remember_dialogue_style(profile_id)
consider_dialogue(profile_id, body)
return {
"conversation": get_conversation(profile_id, conversation_id),
"user": user,
"assistant": assistant,
"calls": calls,
"messages": list_messages(profile_id, conversation_id),
"decision": decision,
"trace": result.get("trace") if result else None,
}

View File

@ -3,11 +3,13 @@ from __future__ import annotations
from typing import Any
import placeholder_mvp # noqa: F401
import placeholder_system # noqa: F401 registers system keys
import privacy_placeholders
from entitlements import EntitlementError, check_feature_access
from db import get_db, row_to_dict
from entitlements import EntitlementError, check_feature_access, increment_feature_usage
from placeholders import PlaceholderError, resolve_template
from privacy_gateway import GatewayRequest, PrivacyGatewayError, complete
from privacy_gateway import GatewayRequest, PrivacyGatewayError, complete, last_trace, public_trace
class EngineError(Exception):
@ -18,6 +20,19 @@ class EngineError(Exception):
self.status_code = status_code
def load_active_prompt(slug: str) -> dict:
with get_db() as conn:
row = row_to_dict(
conn.execute(
"SELECT * FROM ai_prompts WHERE slug = ? AND active = 1",
(slug,),
).fetchone()
)
if not row:
raise EngineError("prompt_missing", f"Prompt fehlt oder ist inaktiv: {slug}", 404)
return row
def preview_prompt(prompt: dict, context: dict[str, Any] | None = None) -> dict:
template = prompt.get("template") or ""
if prompt.get("prompt_type") != "base":
@ -36,6 +51,14 @@ def preview_prompt(prompt: dict, context: dict[str, Any] | None = None) -> dict:
}
def user_source_text(dialogue_context: str) -> str:
parts = []
for line in (dialogue_context or "").splitlines():
if line.startswith("user:"):
parts.append(line[5:].lstrip())
return "\n".join(parts)
def execute_prompt(prompt: dict, profile_id: str, purpose: str, data_class: str, context: dict[str, Any] | None = None) -> dict:
preview = preview_prompt(prompt, context)
feature_id = prompt.get("required_feature") or "ai_calls"
@ -44,14 +67,41 @@ def execute_prompt(prompt: dict, profile_id: str, purpose: str, data_class: str,
except EntitlementError as exc:
raise EngineError(exc.code, exc.message, exc.status_code) from exc
try:
complete(
ctx = context or {}
source_text = user_source_text(ctx.get("dialogue_context") or "")
extras = []
if purpose == "journal_generate":
extras = [part for part in (ctx.get("existing_text"), ctx.get("writing_profile")) if part]
elif purpose == "profile_review":
source_text = ctx.get("source_text") or ""
extras = [part for part in (ctx.get("review_package"),) if part]
if extras:
source_text = "\n".join(part for part in (source_text, *extras) if part)
elif not source_text:
source_text = "\n".join(
part for part in (ctx.get("existing_text"), ctx.get("writing_profile")) if part
)
result = complete(
GatewayRequest(
prompt_id=prompt["id"],
purpose=purpose,
data_class=data_class,
payload={"template_chars": len(preview["rendered"]), "privacy_tokens": preview["privacy_tokens"]},
profile_id=profile_id,
payload={
"rendered": preview["rendered"],
"source_text": source_text,
"privacy_tokens": preview["privacy_tokens"],
"prompt_slug": prompt.get("slug"),
},
)
)
except PrivacyGatewayError as exc:
raise EngineError(exc.code, exc.message, exc.status_code) from exc
raise EngineError("provider_missing", "Gateway erlaubte einen Aufruf ohne konfigurierten Provider", 500)
increment_feature_usage(profile_id, feature_id)
return {
**preview,
"llm": True,
"content": result.content,
"provider": result.provider,
"trace": public_trace(last_trace()),
}

216
backend/entity_detect.py Normal file
View File

@ -0,0 +1,216 @@
"""Entity detection for the privacy gateway. May see plaintext; generate must not."""
from __future__ import annotations
import json
import re
import placeholder_mvp # noqa: F401
from db import get_db, row_to_dict
from env_loader import allows_remote_plaintext_detect
from identity_store import (
coalesce_mappings,
is_given_name_candidate,
is_maskable_label,
list_mappings,
normalize_label,
purge_unmaskable,
remember_mapping,
)
from placeholders import PlaceholderError, resolve_template
from providers import ProviderError, complete_chat, detect_provider
DETECT_MAX_CHARS = 8000
DETECT_TIMEOUT = 20.0
DETECT_MAX_TOKENS = 300
JSON_BLOCK = re.compile(r"\{.*\}", re.DOTALL)
NAME_TITLE = re.compile(r"(?:Frau|Herr)\s+([A-ZÄÖÜ][a-zäöüß]{2,})\b")
NAME_PREP = re.compile(r"(?:mit|von|bei)\s+([A-ZÄÖÜ][a-zäöüß]{2,})\b")
_LETTER = r"A-Za-zÄÖÜäöüß"
PLACE_NAME = re.compile(
r"\b([A-ZÄÖÜ][a-zäöüß]*(?:burg|stadt|dorf|haven|ingen|heim|bach|feld))\b"
)
STOPWORDS = {
"Heute", "Gestern", "Morgen", "Ich", "Wir", "Der", "Die", "Das", "Ein", "Eine",
"Und", "Oder", "Nicht", "Kein", "Keine", "Am", "Im", "Zum", "Zur", "Mit",
"Nach", "Beim", "Über", "Unter", "Aber", "Denn", "Wenn", "Dann", "Also",
"Was", "Wer", "Wie", "Wo", "Warum", "Bitte", "Danke", "Hallo", "Space",
"Journal", "Dialog", "Kontext", "Writing", "Profile", "Tagebucheintrag",
"Relativ", "Allerdings", "Zunächst", "Danach", "Deshalb", "Trotzdem",
"Außerdem", "Schließlich", "Während", "Bevor", "Seitdem",
}
def _detect_prompt() -> dict:
with get_db() as conn:
row = row_to_dict(
conn.execute(
"SELECT * FROM ai_prompts WHERE slug = ? AND active = 1",
("mvp.entity_detect",),
).fetchone()
)
if not row or not (row.get("template") or "").strip():
raise ProviderError(
"detect_prompt_missing",
"Prompt mvp.entity_detect fehlt in der Konfiguration.",
)
return row
def _pattern_entities(text: str, known_labels: set[str]) -> list[dict]:
found: list[dict] = []
seen: set[str] = set()
source = text or ""
candidates: list[tuple[str, str]] = []
for match in NAME_TITLE.finditer(source):
candidates.append((match.group(1).strip(), "PERSON:01"))
for match in NAME_PREP.finditer(source):
name = match.group(1).strip()
if is_given_name_candidate(name):
candidates.append((name, "PERSON:01"))
for match in PLACE_NAME.finditer(source):
candidates.append((match.group(1).strip(), "PLACE:CITY"))
for raw, token in candidates:
label = normalize_label(raw) or raw
if label in STOPWORDS or label in known_labels or label in seen:
continue
if not is_maskable_label(label):
continue
seen.add(label)
found.append({"text": label, "token": token})
return found
def _fake_entities(text: str, known_labels: set[str]) -> list[dict]:
return _pattern_entities(text, known_labels)
def _merge_proposals(*groups: list[dict]) -> list[dict]:
merged: list[dict] = []
seen: set[str] = set()
for group in groups:
for item in group:
label = (item.get("text") or "").strip()
if not label or label in seen:
continue
seen.add(label)
merged.append(item)
return merged
def _parse_entities(raw: str) -> list[dict]:
text = (raw or "").strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text, flags=re.IGNORECASE | re.DOTALL)
match = JSON_BLOCK.search(text)
if not match:
return []
try:
data = json.loads(match.group(0))
except json.JSONDecodeError:
return []
items = data.get("entities") if isinstance(data, dict) else data
if not isinstance(items, list):
return []
result = []
for item in items:
if not isinstance(item, dict):
continue
label = (item.get("text") or item.get("label") or "").strip()
token = (item.get("token") or item.get("placeholder") or "").strip()
if label:
result.append({"text": label, "token": token})
return result
def _label_in_text(label: str, text: str) -> bool:
if not (label or "").strip() or not (text or "").strip():
return False
return bool(
re.search(
rf"(?<![{_LETTER}]){re.escape(label.strip())}(?![{_LETTER}])",
text,
re.IGNORECASE,
)
)
def _acceptable_proposal(item: dict, excerpt: str) -> bool:
label = (item.get("text") or "").strip()
if not label or not _label_in_text(label, excerpt) or not is_maskable_label(label):
return False
token = (item.get("token") or "").upper()
if token.startswith("PLACE:") or token.startswith("ORG:"):
return True
return is_given_name_candidate(label)
def uses_llm_detect(config) -> bool:
"""Local detect stays a provider role. Remote plaintext detect is development/test only."""
if not config or config.mode != "http":
return False
if config.local:
return True
return allows_remote_plaintext_detect()
def _llm_entities(config, excerpt: str, known_labels: set[str]) -> list[dict]:
known = ", ".join(sorted(known_labels)) or "(keine)"
prompt = resolve_template(
_detect_prompt()["template"],
{"source_text": excerpt, "known_labels": known},
)
raw = complete_chat(
config,
[{"role": "user", "content": prompt}],
timeout=DETECT_TIMEOUT,
max_tokens=DETECT_MAX_TOKENS,
)
return _parse_entities(raw)
def detect_and_remember(profile_id: str | None, source_text: str) -> tuple[list[dict], str | None, str | None]:
if profile_id:
purge_unmaskable(profile_id)
coalesce_mappings(profile_id)
mappings = list_mappings(profile_id) if profile_id else []
if not profile_id:
return mappings, None, None
known_labels = {
normalize_label(item.get("local_label") or "") or (item.get("local_label") or "").strip()
for item in mappings
if item.get("local_label")
}
excerpt = (source_text or "")[:DETECT_MAX_CHARS]
proposals = _pattern_entities(excerpt, known_labels)
detect_name = "pattern"
detect_note = "pattern"
config = detect_provider()
if config and config.mode == "fake":
detect_name = config.name
detect_note = "fake"
elif uses_llm_detect(config):
try:
proposals = _merge_proposals(
proposals,
[item for item in _llm_entities(config, excerpt, known_labels) if _acceptable_proposal(item, excerpt)],
)
detect_name = config.name
detect_note = "local_llm" if config.local else "remote_llm"
except PlaceholderError as exc:
raise ProviderError(exc.code, exc.message) from exc
elif config and config.mode == "http" and not config.local:
detect_note = "pattern; remote_detect_blocked_production"
for item in proposals:
label = (item.get("text") or "").strip()
core = normalize_label(label)
if not label or not core or core in known_labels:
continue
if not _acceptable_proposal(item, excerpt):
continue
try:
remember_mapping(profile_id, label, item.get("token"))
except ValueError:
continue
known_labels.add(core)
coalesce_mappings(profile_id)
return list_mappings(profile_id), detect_name, detect_note

68
backend/env_loader.py Normal file
View File

@ -0,0 +1,68 @@
"""Load backend/.env into os.environ without overwriting a real process environment."""
from __future__ import annotations
import os
from pathlib import Path
ENV_PATH = Path(__file__).resolve().parent / ".env"
def load_env_file(path: Path | None = None) -> None:
target = path or ENV_PATH
if not target.is_file():
return
for raw in target.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
if not key or key in os.environ:
continue
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
value = value[1:-1]
os.environ[key] = value
def runtime_env() -> str:
"""development/test may use remote plaintext detect. production must not."""
raw = (os.environ.get("KANSHO_ENV") or "").strip().lower()
if raw in {"production", "prod"}:
return "production"
if raw in {"test", "testing"}:
return "test"
if raw in {"development", "dev"}:
return "development"
return "development"
def allows_remote_plaintext_detect() -> bool:
return runtime_env() != "production"
def upsert_env_value(name: str, value: str, path: Path | None = None) -> None:
"""Write one key into backend/.env and the current process. Never log the value."""
target = path or ENV_PATH
name = name.strip()
if not name:
raise ValueError("empty env name")
lines: list[str] = []
if target.is_file():
lines = target.read_text(encoding="utf-8").splitlines()
found = False
updated: list[str] = []
prefix = f"{name}="
for line in lines:
stripped = line.strip()
if stripped.startswith(prefix) or stripped.startswith(f"{name} ="):
updated.append(f"{name}={value}")
found = True
else:
updated.append(line)
if not found:
if updated and updated[-1] != "":
updated.append("")
updated.append(f"{name}={value}")
target.write_text("\n".join(updated) + "\n", encoding="utf-8")
os.environ[name] = value

282
backend/identity_store.py Normal file
View File

@ -0,0 +1,282 @@
"""Local identity mappings. Class A; never part of an external prompt."""
from __future__ import annotations
import json
import re
import uuid
from functools import lru_cache
from pathlib import Path
from db import get_db, row_to_dict
PROMPTS_SEED_PATH = Path(__file__).resolve().parent / "config" / "prompts.seed.json"
TOKEN_RE = re.compile(r"^(SELF|PERSON:[A-Z][A-Z0-9_]{0,24}|PLACE:[A-Z][A-Z0-9_]{0,24}|ORG:[A-Z][A-Z0-9_]{0,24})$")
MIN_LABEL_LEN = 4
TIME_RE = re.compile(r"\d|:\d|\buhr\b|\bminuten\b|\bstunde\b", re.IGNORECASE)
DETERMINERS = {
"der", "die", "das", "den", "dem", "des",
"ein", "eine", "einen", "einem", "eines",
"mein", "meine", "meinen", "meinem", "meiner", "meines",
"dein", "deine", "sein", "seine", "ihr", "ihre",
"unser", "unsere", "euer", "eure",
}
KINSHIP = {"frau", "herr", "mann", "kind", "tochter", "sohn", "partner", "partnerin"}
UNMASKABLE = {
"ich", "du", "er", "sie", "es", "wir", "ihr", "mich", "mir", "mein", "meine",
"dir", "dich", "uns", "euch", "sein", "seine", "ihre",
"heute", "gestern", "morgen", "user", "assistant", "self", "kontext",
"himmel", "luft", "regen", "sonne", "wind", "meer", "tee", "kaffee",
"balkon", "laden", "brot", "brote", "bad", "gasse", "gassen", "weg",
"ort", "ortes", "stadt", "zimmer", "haus",
"frau", "herr", "mann", "kind", "lektion", "bootstour", "frühstück",
"tagebuch", "omlett", "haferflocken", "speiseplan",
"impuls", "tatsache", "motivation", "erzählung", "operation",
"uhr", "zeit", "stunde", "minute", "tag", "tages", "abend", "nacht",
"warm", "klar", "draußen", "menge", "teil", "stück",
"relativ", "allerdings", "zunächst", "danach", "deshalb", "trotzdem",
"außerdem", "schließlich", "während", "bevor", "seitdem", "deswegen",
"überlegen", "feststellen", "frage", "antwort",
"markt", "kirschen", "hafen", "wetter", "vormittag", "nachmittag",
"anreise", "details", "absatz", "essen", "licht",
"zwiebeln", "zwiebel", "tomaten", "tomate", "paprika",
"omelett", "trockenobst", "trockenfrüchte", "glutenfrei",
"schiff", "boot", "zweimaster", "fähre",
}
FUNCTION_STEMS = ("letzt", "nächst")
NOUN_SUFFIXES = ("heit", "keit", "schaft", "tum")
COLLECTIVE_NOUN = re.compile(r"^ge[a-zäöüß]+öse$", re.IGNORECASE)
RESERVED_TOKEN_SUFFIXES = {"KURZ", "NAME", "TEXT", "LABEL", "EXAMPLE", "FOO", "BAR"}
@lru_cache(maxsize=1)
def instruction_vocab() -> frozenset[str]:
"""Words from Kanshō prompts must never become identity labels."""
items = json.loads(PROMPTS_SEED_PATH.read_text(encoding="utf-8"))
words: set[str] = set()
for item in items:
if (item.get("slug") or "") == "mvp.entity_detect":
continue
template = item.get("template") or ""
template = re.sub(r"\{\{[^}]+\}\}|\[\[[^\]]+\]\]", " ", template)
for word in re.findall(r"[A-Za-zÄÖÜäöüß]{4,}", template):
words.add(word.casefold())
return frozenset(words)
def is_function_label(label: str) -> bool:
word = (label or "").strip().casefold()
if not word:
return False
for stem in FUNCTION_STEMS:
if word == stem or word.startswith(stem):
return True
return False
def is_common_noun_shape(label: str) -> bool:
word = (label or "").strip()
if COLLECTIVE_NOUN.match(word):
return True
lower = word.casefold()
if lower.endswith("ung") and len(lower) > 6:
return True
return any(lower.endswith(suffix) for suffix in NOUN_SUFFIXES)
def is_given_name_candidate(label: str) -> bool:
core = normalize_label(label)
if not core or " " in core:
return False
if is_function_label(core) or is_common_noun_shape(core):
return False
return is_maskable_label(core)
def normalize_label(label: str) -> str:
words = re.findall(r"[A-Za-zÄÖÜäöüß0-9.:]+", label or "")
keep = [
word
for word in words
if word.lower() not in DETERMINERS
and word.lower() not in KINSHIP
and word.lower() not in UNMASKABLE
]
return " ".join(keep).strip()
def is_maskable_label(label: str) -> bool:
core = normalize_label(label)
if len(core) < MIN_LABEL_LEN:
return False
if core.lower() in UNMASKABLE:
return False
if is_function_label(core) or is_common_noun_shape(core):
return False
if core.casefold() in instruction_vocab():
return False
if TIME_RE.search(core):
return False
if re.fullmatch(r"user|assistant|self", core, re.IGNORECASE):
return False
if not re.search(r"[A-Za-zÄÖÜäöüß]", core):
return False
return True
def purge_unmaskable(profile_id: str) -> int:
removed = 0
with get_db() as conn:
rows = conn.execute(
"SELECT id, local_label FROM identity_mappings WHERE profile_id = ?",
(profile_id,),
).fetchall()
for row in rows:
if is_maskable_label(row["local_label"]):
continue
conn.execute("DELETE FROM identity_mappings WHERE id = ?", (row["id"],))
removed += 1
return removed
def list_mappings(profile_id: str) -> list[dict]:
with get_db() as conn:
rows = conn.execute(
"SELECT token, local_label FROM identity_mappings WHERE profile_id = ? ORDER BY created",
(profile_id,),
).fetchall()
return [row_to_dict(row) for row in rows]
def _token_suffix(token: str) -> str:
raw = (token or "").upper()
return raw.split(":")[-1] if ":" in raw else raw
def normalize_token(token: str | None) -> str | None:
raw = (token or "").strip().upper().replace(" ", "_")
raw = raw[2:-2] if raw.startswith("[[") and raw.endswith("]]") else raw
if not TOKEN_RE.match(raw):
return None
if raw.count("_") > 1:
return None
if _token_suffix(raw) in RESERVED_TOKEN_SUFFIXES:
return None
return raw
def _same_core(left: str, right: str) -> bool:
a = normalize_label(left).casefold()
b = normalize_label(right).casefold()
return bool(a and b and a == b)
def find_existing_mapping(existing: list[dict], label: str) -> dict | None:
core = normalize_label(label).casefold()
if not core:
return None
for item in existing:
stored = (item.get("local_label") or "").strip()
if stored.casefold() == label.casefold() or _same_core(stored, label):
return item
return None
def _pick_keeper(rows: list) -> dict:
def sort_key(row: dict) -> tuple:
suffix = _token_suffix(row["token"] or "")
reserved = 1 if suffix in RESERVED_TOKEN_SUFFIXES else 0
numeric = 0 if suffix.isdigit() else 1
return (reserved, numeric, row["created"] or "", row["id"] or "")
return min(rows, key=sort_key)
def coalesce_mappings(profile_id: str) -> int:
"""One token per normalized name. Template tokens like PERSON:KURZ lose against PERSON:08."""
merged = 0
with get_db() as conn:
rows = [
row_to_dict(row)
for row in conn.execute(
"""
SELECT id, token, local_label, created
FROM identity_mappings
WHERE profile_id = ?
ORDER BY created
""",
(profile_id,),
).fetchall()
]
groups: dict[str, list[dict]] = {}
for row in rows:
core = normalize_label(row.get("local_label") or "")
if not core or not is_maskable_label(core):
conn.execute("DELETE FROM identity_mappings WHERE id = ?", (row["id"],))
merged += 1
continue
groups.setdefault(core.casefold(), []).append({**row, "core": core})
for items in groups.values():
keeper = _pick_keeper(items)
core = items[0]["core"]
if (keeper.get("local_label") or "") != core:
conn.execute(
"UPDATE identity_mappings SET local_label = ? WHERE id = ?",
(core, keeper["id"]),
)
for row in items:
if row["id"] == keeper["id"]:
continue
conn.execute("DELETE FROM identity_mappings WHERE id = ?", (row["id"],))
merged += 1
return merged
def _next_generic(existing: list[dict], prefix: str) -> str:
used = set()
for item in existing:
token = (item.get("token") or "").upper()
if token.startswith(prefix):
suffix = token[len(prefix) :]
if suffix.isdigit():
used.add(int(suffix))
n = 1
while n in used:
n += 1
return f"{prefix}{n:02d}"
def remember_mapping(profile_id: str, local_label: str, suggested_token: str | None = None) -> dict:
label = normalize_label(local_label)
if not label or not is_maskable_label(label):
raise ValueError("empty_label")
existing = list_mappings(profile_id)
found = find_existing_mapping(existing, label)
if found:
stored = (found.get("local_label") or "").strip()
if stored != label:
with get_db() as conn:
conn.execute(
"""
UPDATE identity_mappings
SET local_label = ?
WHERE profile_id = ? AND token = ?
""",
(label, profile_id, found["token"]),
)
found = {**found, "local_label": label}
return found
token = normalize_token(suggested_token)
if token and any((item.get("token") or "").upper() == token for item in existing):
token = None
if not token:
token = _next_generic(existing, "PERSON:")
with get_db() as conn:
conn.execute(
"""
INSERT INTO identity_mappings (id, profile_id, token, local_label)
VALUES (?, ?, ?, ?)
""",
(str(uuid.uuid4()), profile_id, token, label),
)
return {"token": token, "local_label": label}

View File

@ -0,0 +1,32 @@
"""Labeled product defaults for dialogue interaction. Not a personality model.
These are visible, overridable defaults not silent personal facts inferred from
the mere fact that a dialogue happened.
"""
from __future__ import annotations
DEFAULTS = (
{
"scope": "global",
"pref_key": "reply_length",
"title": "Antwortlänge",
"value": "Ein bis drei Sätze.",
},
{
"scope": "global",
"pref_key": "follow_up",
"title": "Anschlussimpuls",
"value": "Nur einen dominanten Anschlussimpuls setzen, nicht mehrere Fragen stapeln.",
},
{
"scope": "autobiographical",
"pref_key": "listening",
"title": "Zuhören bei Erzählung",
"value": "Bei längeren autobiografischen Erzählungen zunächst stärker zuhören und wenig unterbrechen.",
},
)
SCOPES = ("global", "autobiographical")
PREF_KEYS = tuple(item["pref_key"] for item in DEFAULTS)
GOVERNANCE = {"learning", "advising", "frozen"}
DEFAULT_GOVERNANCE = "advising"

View File

@ -0,0 +1,423 @@
"""Interaction profile: stable preferences, never learned from silence.
Strong sources only: explicit instruction, explicit feedback, manual setting,
accepted suggestion. Observations may be stored as suggestions, never auto-applied
from a dialogue turn.
"""
from __future__ import annotations
import uuid
from db import get_db, row_to_dict
from dialogue_store import StoreError
from interaction_defaults import DEFAULT_GOVERNANCE, DEFAULTS, GOVERNANCE, PREF_KEYS, SCOPES
from profile_documents import (
FORMAT_VERSION,
KIND_INTERACTION,
ORIGIN_INTERACTION,
exported_at,
interaction_payload,
)
__all__ = [
"GOVERNANCE",
"SCOPES",
"get_profile",
"set_governance",
"upsert_preference",
"accept_suggestion",
"reject_suggestion",
"propose_observation",
"assemble_interaction_hint",
"export_document",
"restore_document",
]
def ensure_profile(profile_id: str) -> dict:
with get_db() as conn:
row = row_to_dict(
conn.execute("SELECT * FROM interaction_profiles WHERE profile_id = ?", (profile_id,)).fetchone()
)
if row:
return row
conn.execute(
"""
INSERT INTO interaction_profiles (profile_id, governance)
VALUES (?, ?)
""",
(profile_id, DEFAULT_GOVERNANCE),
)
return row_to_dict(
conn.execute("SELECT * FROM interaction_profiles WHERE profile_id = ?", (profile_id,)).fetchone()
)
def get_profile(profile_id: str) -> dict:
row = ensure_profile(profile_id)
with get_db() as conn:
prefs = [
row_to_dict(item)
for item in conn.execute(
"""
SELECT * FROM interaction_preferences
WHERE profile_id = ?
ORDER BY CASE scope WHEN 'global' THEN 0 ELSE 1 END, pref_key
""",
(profile_id,),
).fetchall()
]
suggestions = [
row_to_dict(item)
for item in conn.execute(
"""
SELECT * FROM interaction_suggestions
WHERE profile_id = ? AND status = 'pending'
ORDER BY created DESC
""",
(profile_id,),
).fetchall()
]
for item in prefs:
item["locked"] = bool(item.get("locked"))
item["title"] = _title(item.get("scope") or "global", item.get("pref_key") or "")
defaults = [
{
**item,
"source": "product_default",
"overridden": any(
pref.get("scope") == item["scope"] and pref.get("pref_key") == item["pref_key"]
for pref in prefs
),
}
for item in DEFAULTS
]
return {
"profile_id": profile_id,
"governance": (row or {}).get("governance") or DEFAULT_GOVERNANCE,
"updated": (row or {}).get("updated"),
"preferences": prefs,
"defaults": defaults,
"suggestions": suggestions,
"note": (
"Persönliche Interaction-Präferenzen entstehen nur durch ausdrückliche Angabe, "
"Feedback, manuelle Einstellung oder angenommene Vorschläge. "
"Ein Dialog ohne Widerspruch ist kein Lernsignal."
),
}
def set_governance(profile_id: str, governance: str) -> dict:
mode = (governance or "").strip()
if mode not in GOVERNANCE:
raise StoreError("invalid_governance", "governance muss learning, advising oder frozen sein")
ensure_profile(profile_id)
with get_db() as conn:
conn.execute(
"UPDATE interaction_profiles SET governance = ?, updated = datetime('now') WHERE profile_id = ?",
(mode, profile_id),
)
return get_profile(profile_id)
def upsert_preference(
profile_id: str,
*,
scope: str,
pref_key: str,
value: str,
origin: str = "manual",
locked: bool | None = None,
evidence: str = "manuelle Einstellung",
) -> dict:
if scope not in SCOPES:
raise StoreError("unknown_scope", "Unbekannter Interaction-Scope.")
if pref_key not in PREF_KEYS:
raise StoreError("unknown_pref", "Unbekannte Interaction-Präferenz.")
if origin not in {"explicit", "manual", "accepted_suggestion"}:
raise StoreError("invalid_origin", "Nur explizite, manuelle oder angenommene Herkunft.")
ensure_profile(profile_id)
with get_db() as conn:
current = row_to_dict(
conn.execute(
"""
SELECT * FROM interaction_preferences
WHERE profile_id = ? AND scope = ? AND pref_key = ?
""",
(profile_id, scope, pref_key),
).fetchone()
)
next_locked = int(locked) if locked is not None else int((current or {}).get("locked") or 0)
if current:
conn.execute(
"""
UPDATE interaction_preferences
SET value = ?, evidence = ?, origin = ?, locked = ?, updated = datetime('now')
WHERE id = ?
""",
(value, evidence, origin, next_locked, current["id"]),
)
else:
conn.execute(
"""
INSERT INTO interaction_preferences
(id, profile_id, scope, pref_key, value, evidence, origin, locked)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(str(uuid.uuid4()), profile_id, scope, pref_key, value, evidence, origin, next_locked),
)
conn.execute(
"UPDATE interaction_profiles SET updated = datetime('now') WHERE profile_id = ?",
(profile_id,),
)
return get_profile(profile_id)
def propose_observation(
profile_id: str,
*,
scope: str,
pref_key: str,
proposed_value: str,
evidence: str,
) -> dict | None:
"""Candidate only. Never applied here. Not called from the dialogue turn."""
if scope not in SCOPES or pref_key not in PREF_KEYS:
return None
ensure_profile(profile_id)
row = ensure_profile(profile_id)
if (row.get("governance") or DEFAULT_GOVERNANCE) == "frozen":
return None
with get_db() as conn:
current = row_to_dict(
conn.execute(
"""
SELECT locked FROM interaction_preferences
WHERE profile_id = ? AND scope = ? AND pref_key = ?
""",
(profile_id, scope, pref_key),
).fetchone()
)
if current and int(current.get("locked") or 0):
return None
pending = row_to_dict(
conn.execute(
"""
SELECT id, proposed_value FROM interaction_suggestions
WHERE profile_id = ? AND scope = ? AND pref_key = ? AND status = 'pending'
ORDER BY created DESC LIMIT 1
""",
(profile_id, scope, pref_key),
).fetchone()
)
if pending and (pending.get("proposed_value") or "") == proposed_value:
return get_profile(profile_id)
if pending:
conn.execute(
"UPDATE interaction_suggestions SET status = 'rejected', resolved = datetime('now') WHERE id = ?",
(pending["id"],),
)
conn.execute(
"""
INSERT INTO interaction_suggestions
(id, profile_id, scope, pref_key, proposed_value, evidence, status)
VALUES (?, ?, ?, ?, ?, ?, 'pending')
""",
(str(uuid.uuid4()), profile_id, scope, pref_key, proposed_value, evidence),
)
return get_profile(profile_id)
def accept_suggestion(profile_id: str, suggestion_id: str) -> dict:
with get_db() as conn:
row = row_to_dict(
conn.execute(
"""
SELECT * FROM interaction_suggestions
WHERE id = ? AND profile_id = ? AND status = 'pending'
""",
(suggestion_id, profile_id),
).fetchone()
)
if not row:
raise StoreError("not_found", "Vorschlag nicht gefunden", 404)
upsert_preference(
profile_id,
scope=row.get("scope") or "global",
pref_key=row["pref_key"],
value=row.get("proposed_value") or "",
origin="accepted_suggestion",
evidence=row.get("evidence") or "angenommener Vorschlag",
)
with get_db() as conn:
conn.execute(
"UPDATE interaction_suggestions SET status = 'accepted', resolved = datetime('now') WHERE id = ?",
(suggestion_id,),
)
return get_profile(profile_id)
def reject_suggestion(profile_id: str, suggestion_id: str) -> dict:
with get_db() as conn:
row = row_to_dict(
conn.execute(
"""
SELECT * FROM interaction_suggestions
WHERE id = ? AND profile_id = ? AND status = 'pending'
""",
(suggestion_id, profile_id),
).fetchone()
)
if not row:
raise StoreError("not_found", "Vorschlag nicht gefunden", 404)
conn.execute(
"UPDATE interaction_suggestions SET status = 'rejected', resolved = datetime('now') WHERE id = ?",
(suggestion_id,),
)
return get_profile(profile_id)
def export_document(profile_id: str) -> dict:
profile = get_profile(profile_id)
return {
"kind": KIND_INTERACTION,
"format_version": FORMAT_VERSION,
"exported_at": exported_at(),
"governance": profile.get("governance") or DEFAULT_GOVERNANCE,
"preferences": [
{
"scope": item.get("scope") or "global",
"pref_key": item.get("pref_key"),
"value": item.get("value") or "",
"evidence": item.get("evidence") or "",
"origin": item.get("origin") or "manual",
"locked": bool(item.get("locked")),
}
for item in profile.get("preferences") or []
if item.get("pref_key")
],
}
def restore_document(profile_id: str, document: dict) -> dict:
payload = interaction_payload(document)
governance = (payload.get("governance") or DEFAULT_GOVERNANCE).strip()
if governance not in GOVERNANCE:
raise StoreError("invalid_governance", "governance muss learning, advising oder frozen sein")
prefs = payload.get("preferences")
if prefs is None:
raise StoreError("invalid_document", "Interaction-Profile-Dokument braucht preferences.")
if not isinstance(prefs, list):
raise StoreError("invalid_document", "preferences muss eine Liste sein.")
prepared: list[dict] = []
seen: set[tuple[str, str]] = set()
for item in prefs:
if not isinstance(item, dict):
raise StoreError("invalid_document", "Jede Präferenz muss ein Objekt sein.")
scope = (item.get("scope") or "global").strip()
pref_key = (item.get("pref_key") or "").strip()
if scope not in SCOPES:
raise StoreError("unknown_scope", f"Unbekannter Interaction-Scope: {scope}")
if pref_key not in PREF_KEYS:
raise StoreError("unknown_pref", f"Unbekannte Interaction-Präferenz: {pref_key or ''}")
key = (scope, pref_key)
if key in seen:
raise StoreError("duplicate_pref", f"Präferenz {scope}/{pref_key} ist doppelt.")
origin = (item.get("origin") or "manual").strip()
if origin not in ORIGIN_INTERACTION:
origin = "manual"
seen.add(key)
prepared.append(
{
"scope": scope,
"pref_key": pref_key,
"value": item.get("value") or "",
"evidence": item.get("evidence") or "aus Profil-Dokument",
"origin": origin,
"locked": 1 if item.get("locked") else 0,
}
)
ensure_profile(profile_id)
with get_db() as conn:
conn.execute(
"UPDATE interaction_profiles SET governance = ?, updated = datetime('now') WHERE profile_id = ?",
(governance, profile_id),
)
conn.execute("DELETE FROM interaction_preferences WHERE profile_id = ?", (profile_id,))
conn.execute(
"""
UPDATE interaction_suggestions
SET status = 'rejected', resolved = datetime('now')
WHERE profile_id = ? AND status = 'pending'
""",
(profile_id,),
)
for item in prepared:
conn.execute(
"""
INSERT INTO interaction_preferences
(id, profile_id, scope, pref_key, value, evidence, origin, locked)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
str(uuid.uuid4()),
profile_id,
item["scope"],
item["pref_key"],
item["value"],
item["evidence"],
item["origin"],
item["locked"],
),
)
return get_profile(profile_id)
def assemble_interaction_hint(profile_id: str | None, state: dict | None = None) -> str:
"""Visible hint for the dialogue turn. Product defaults plus personal overrides plus current state."""
prefs: dict[tuple[str, str], dict] = {}
if profile_id:
profile = get_profile(profile_id)
for item in profile.get("preferences") or []:
prefs[(item.get("scope") or "global", item.get("pref_key") or "")] = item
lines = [
"Interaction (sichtbar, überschreibbar; Nicht-Widersprechen ist kein Lernsignal):",
]
for item in DEFAULTS:
key = (item["scope"], item["pref_key"])
personal = prefs.get(key)
if item["scope"] != "global":
continue
value = (personal.get("value") if personal else None) or item["value"]
source = "persönlich" if personal else "Produktdefault"
lines.append(f"- {item['title']}: {value} [{source}]")
auto = next((item for item in DEFAULTS if item["pref_key"] == "listening"), None)
if auto:
personal = prefs.get(("autobiographical", "listening"))
value = (personal.get("value") if personal else None) or auto["value"]
source = "persönlich" if personal else "Produktdefault"
if state and (state.get("long_story") or (state.get("narrative_mode") or "") == "chronicle"):
lines.append(f"- {auto['title']}: {value} [{source}]")
elif personal:
lines.append(f"- {auto['title']}: {value} [{source}]")
if state:
mode = (state.get("narrative_mode") or "open").strip() or "open"
depth = (state.get("reflection_depth") or "surface").strip() or "surface"
intensity = (state.get("emotional_intensity") or "low").strip() or "low"
long_story = "ja" if state.get("long_story") else "nein"
focus = (state.get("current_focus") or "").strip()
lines.append(
"Aktueller Dialogue State (operativ, kein Profil, ändert sich von Zug zu Zug): "
f"narrative_mode={mode}; reflection_depth={depth}; "
f"emotional_intensity={intensity}; long_story={long_story}."
)
if focus:
lines.append(f"current_focus={focus}")
return "\n".join(lines)
def _title(scope: str, pref_key: str) -> str:
for item in DEFAULTS:
if item["scope"] == scope and item["pref_key"] == pref_key:
return item["title"]
return pref_key

70
backend/journal_body.py Normal file
View File

@ -0,0 +1,70 @@
"""Journal entry body as Markdown. Media stays in media_assets; the body only references it.
Canonical embed: ![caption](kansho-media:<uuid>)
Legacy [[media:<uuid>|caption]] is still read and normalised on save.
Tokens never go to the Privacy Gateway; captions may, as user wording.
"""
from __future__ import annotations
import re
UUID = r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
UUID_RE = re.compile(rf"^{UUID}$")
MD_MEDIA_RE = re.compile(rf"!\[([^\]]*)\]\(kansho-media:({UUID})\)")
LEGACY_MEDIA_RE = re.compile(rf"\[\[media:({UUID})(?:\|([^\]]*))?\]\]")
HEADING_PREFIX = re.compile(r"^#{1,6}\s+")
def clean_title(raw: str) -> str:
"""First line only, without Markdown heading marks."""
line = (raw or "").strip().splitlines()[0].strip() if raw else ""
return HEADING_PREFIX.sub("", line).strip()
def media_token(media_id: str, caption: str = "") -> str:
cap = (caption or "").replace("]", "").replace("\n", " ").strip()
return f"![{cap}](kansho-media:{media_id})"
def media_ids(body: str) -> list[str]:
seen: list[str] = []
for match in MD_MEDIA_RE.finditer(body or ""):
media_id = match.group(2)
if media_id not in seen:
seen.append(media_id)
for match in LEGACY_MEDIA_RE.finditer(body or ""):
media_id = match.group(1)
if media_id not in seen:
seen.append(media_id)
return seen
def to_markdown(body: str) -> str:
"""Legacy media tokens become Markdown image syntax. Already-Markdown is unchanged."""
def repl(match: re.Match) -> str:
return media_token(match.group(1), match.group(2) or "")
return LEGACY_MEDIA_RE.sub(repl, body or "")
def plain_text(body: str) -> str:
"""User wording only: no media URLs, no asset IDs, no Markdown markup. Captions stay."""
text = to_markdown(body)
def caption(match: re.Match) -> str:
return (match.group(1) or "").strip()
text = MD_MEDIA_RE.sub(caption, text)
text = re.sub(r"<u>(.*?)</u>", r"\1", text, flags=re.I | re.S)
text = re.sub(r"^#{1,6}\s+", "", text, flags=re.M)
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
text = re.sub(r"__(.+?)__", r"\1", text)
text = re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"\1", text)
text = re.sub(r"^\s*[-*+]\s+", "", text, flags=re.M)
text = re.sub(r"^\s*\d+\.\s+", "", text, flags=re.M)
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
text = re.sub(r"`([^`]+)`", r"\1", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()

110
backend/journal_generate.py Normal file
View File

@ -0,0 +1,110 @@
"""Explicit journal draft generation. Never overwrites the current user entry."""
from __future__ import annotations
from context_builder import assemble_text, build_internal_context
from dialogue_store import StoreError, list_conversations_for_day, list_messages
from engine import execute_prompt, load_active_prompt
from journal_policy import require_explicit_generate, source_conversation_ids
from identity_store import list_mappings
from journal_body import clean_title
from journal_shape import shape_journal
from journal_store import current_draft, current_entries, get_day, insert_draft
from writing_profile_store import compile_style_signals, remember_dialogue_style
def _message_ids(profile_id: str, conversation_ids: list[str]) -> list[str]:
ids: list[str] = []
for conversation_id in conversation_ids:
for message in list_messages(profile_id, conversation_id):
ids.append(message["id"])
return ids
def _split_title(content: str) -> tuple[str, str]:
text = (content or "").strip()
if not text:
return "", ""
lines = text.splitlines()
title = clean_title(lines[0])
body = "\n".join(lines[1:]).strip() if len(lines) > 1 else text
if not title or len(title) > 80:
return "", text
return title, body or text
def generate_draft(
profile_id: str,
journal_day_id: str,
conversation_ids: list[str] | None = None,
include_existing: bool = False,
explicit: bool = True,
) -> dict:
require_explicit_generate(explicit)
day = get_day(profile_id, journal_day_id)
day_conversations = list_conversations_for_day(profile_id, journal_day_id)
selected = source_conversation_ids(
conversation_ids,
[item["id"] for item in day_conversations],
)
remember_dialogue_style(profile_id, exclude_conversation_ids=selected)
existing_text = ""
if include_existing:
draft = current_draft(profile_id, journal_day_id)
entries = current_entries(profile_id, journal_day_id)
if draft:
existing_text = draft.get("body") or ""
elif entries:
existing_text = entries[0].get("body") or ""
context = build_internal_context(
profile_id,
space_id=day["space_id"],
journal_day_id=journal_day_id,
purpose="journal_generate",
include_existing=include_existing,
conversation_ids=selected,
existing_text=existing_text,
conversation_id=selected[0] if selected else None,
)
assembled = assemble_text(context)
source_user = [
message.get("body") or ""
for conversation_id in selected
for message in list_messages(profile_id, conversation_id)
if message.get("role") == "user"
]
signals = compile_style_signals(source_user)
profile = assembled.get("writing_profile") or ""
if signals and signals not in profile:
assembled["writing_profile"] = "\n\n".join(part for part in (profile, signals) if part)
prompt = load_active_prompt("mvp.journal_generate")
result = execute_prompt(
prompt,
profile_id,
purpose="journal_generate",
data_class="B",
context=assembled,
)
title, body = _split_title(result.get("content") or "")
person_labels = [
(item.get("local_label") or "").strip()
for item in list_mappings(profile_id)
if (item.get("local_label") or "").strip()
and str(item.get("token") or "").upper().lstrip("[").startswith("PERSON:")
]
title, body = shape_journal(title, body, source_user, person_labels=person_labels)
before_entries = {item["id"]: item.get("current_version_id") for item in current_entries(profile_id, journal_day_id)}
draft = insert_draft(
profile_id,
journal_day_id,
title=title,
body=body,
source_conversation_ids=selected,
source_message_ids=_message_ids(profile_id, selected),
)
after_entries = current_entries(profile_id, journal_day_id)
for item in after_entries:
previous = before_entries.get(item["id"])
if previous is not None and previous != item.get("current_version_id"):
raise StoreError("policy_violation", "Generate darf die Nutzerfassung nicht verändern", 500)
draft["trace"] = result.get("trace")
return draft

51
backend/journal_policy.py Normal file
View File

@ -0,0 +1,51 @@
"""Small write/decision boundary for the journal MVP. Not a policy engine."""
from __future__ import annotations
from conversation_signals import similar_enough
class PolicyError(Exception):
def __init__(self, code: str, message: str, status_code: int = 400):
super().__init__(message)
self.code = code
self.message = message
self.status_code = status_code
ENTRY_ORIGINS = {"user_edit", "accepted_draft", "restore"}
def require_explicit_generate(explicit: bool) -> None:
if not explicit:
raise PolicyError("generate_not_explicit", "Journal Drafts entstehen nur auf ausdrückliche Aktion.")
def consolidation_offer(conversations: list[dict] | int) -> bool:
"""Offer merge only when conversations look narratively compatible. User decides."""
if isinstance(conversations, int):
return False
return similar_enough(conversations)
def source_conversation_ids(explicit_ids: list[str] | None, day_conversation_ids: list[str]) -> list[str]:
"""Missing selection never silently merges multiple conversations."""
known = [item for item in day_conversation_ids if item]
if explicit_ids:
chosen = [item for item in explicit_ids if item in known]
if not chosen:
raise PolicyError("unknown_conversation", "Keine der angegebenen Conversations gehört zu diesem Tag.")
return chosen
if not known:
raise PolicyError("no_source_dialogue", "Ohne Dialog gibt es nichts zu erzeugen.")
return [known[-1]]
def draft_must_not_touch_entry() -> None:
return None
def require_origin(origin: str) -> str:
if origin not in ENTRY_ORIGINS:
raise PolicyError("invalid_origin", "origin muss user_edit, accepted_draft oder restore sein")
return origin

186
backend/journal_shape.py Normal file
View File

@ -0,0 +1,186 @@
"""Local journal shape after the model. Does not invent content."""
from __future__ import annotations
import re
NEW_PHASE = re.compile(
r"^(?:Heute|Gestern|Morgens|Mittags|Abends|Nachts|Danach|Später|Dann|Zuerst|"
r"Schließlich|Irgendwann|Unterwegs|Zurück|"
r"Um\s+\d|Nach\s+(?:ca\.|einer|einem|dem|der|kurzer|weiteren)|"
r"Als\s+(?:wir|ich)\b)",
re.I,
)
_LETTER = r"A-Za-zÄÖÜäöüß"
_WORD = re.compile(rf"[{_LETTER}]+")
DATIVE_PREPS = {
"mit", "bei", "von", "zu", "nach", "aus", "außer", "gegenüber", "gemäß",
}
NOM_CUES = {
"dass", "daß", "ob", "weil", "wenn", "als", "während", "obwohl", "damit",
"nachdem", "bevor", "sodass", "sofern",
}
THING_GOVERNORS = {
"esse", "essen", "isst", "", "aßest", "gegessen",
"koche", "kochen", "kochte", "gekocht",
"trinke", "trinken", "trank", "getrunken",
"kaufe", "kaufen", "kaufte", "gekauft",
"bestelle", "bestellen", "bestellte",
"hole", "holen", "holte",
}
FEM_HINTS = {"sie", "ihr", "ihre", "ihrem", "ihren", "ihrer", "ihres"}
MASC_HINTS = {"er", "ihn", "ihm", "seine", "seinem", "seinen", "seiner", "seines"}
KEEP_NAMES_PER_PARAGRAPH = 2
def _strip_roles(text: str) -> str:
lines = [re.sub(r"^(?:user|assistant):\s*", "", line, flags=re.I) for line in (text or "").splitlines()]
return "\n".join(lines).strip()
def _norm(text: str) -> str:
return re.sub(r"[^\w\s]+", " ", re.sub(r"\s+", " ", _strip_roles(text).lower())).strip()
def _turns(user_bodies: list[str]) -> list[str]:
return [re.sub(r"\s+", " ", item).strip() for item in user_bodies if (item or "").strip()]
def is_verbatim_join(body: str, user_bodies: list[str]) -> bool:
turns = _turns(user_bodies)
if not turns or not (body or "").strip():
return False
got = _norm(body)
return got == _norm(" ".join(turns)) or got == _norm("\n\n".join(turns))
def paragraphize(body: str) -> str:
text = (body or "").strip()
if "\n\n" in text:
return text
sentences = [item.strip() for item in re.split(r"(?<=[.!?])\s+", text) if item.strip()]
if len(sentences) < 4:
return text
chunks: list[str] = []
current: list[str] = []
for sentence in sentences:
split = bool(current) and (NEW_PHASE.match(sentence) or len(current) >= 3)
if split:
chunks.append(" ".join(current))
current = [sentence]
else:
current.append(sentence)
if current:
chunks.append(" ".join(current))
return "\n\n".join(chunks)
def _words_before(text: str, index: int, n: int = 4) -> list[str]:
return [word.lower() for word in _WORD.findall(text[:index])[-n:]]
def _words_after(text: str, index: int, n: int = 3) -> list[str]:
return [word.lower() for word in _WORD.findall(text[index:])[:n]]
def _is_thing_mention(text: str, start: int, end: int) -> bool:
prev = _words_before(text, start)
nxt = _words_after(text, end)
return any(word in THING_GOVERNORS for word in prev + nxt)
def _sentence_start(text: str, index: int) -> bool:
prefix = text[:index].rstrip()
return not prefix or prefix[-1] in ".!?\n"
def infer_person_gender(label: str, source_texts: list[str]) -> str | None:
blob = " ".join(source_texts or [])
if not label or not blob:
return None
pattern = re.compile(rf"(?<![{_LETTER}]){re.escape(label)}(?![{_LETTER}])", re.I)
first = pattern.search(blob)
if not first:
return None
after = [word.lower() for word in _WORD.findall(blob[first.start():])]
fem = sum(1 for word in after if word in FEM_HINTS)
masc = sum(1 for word in after if word in MASC_HINTS)
if fem > masc:
return "f"
if masc > fem:
return "m"
return None
def _pronoun(gender: str, prev: str, sentence_start: bool) -> str:
dative = prev in DATIVE_PREPS
if gender == "f":
form = "ihr" if dative else "sie"
elif sentence_start or prev in NOM_CUES:
form = "er"
elif dative:
form = "ihm"
else:
form = "ihn"
if sentence_start:
return form[:1].upper() + form[1:]
return form
def naturalize_person_mentions(body: str, labels: list[str], source_texts: list[str]) -> str:
"""After demask: keep the name once or twice per passage, then pronouns. No new facts."""
text = body or ""
people = []
for label in sorted({item.strip() for item in (labels or []) if (item or "").strip()}, key=len, reverse=True):
gender = infer_person_gender(label, source_texts)
if gender:
people.append((label, gender))
if not people:
return text
paragraphs = re.split(r"(\n\n+)", text)
out: list[str] = []
for block in paragraphs:
if not block or block.startswith("\n"):
out.append(block)
continue
piece = block
for label, gender in people:
pattern = re.compile(rf"(?<![{_LETTER}]){re.escape(label)}(?![{_LETTER}])", re.I)
matches = [
match
for match in pattern.finditer(piece)
if not _is_thing_mention(piece, match.start(), match.end())
]
if len(matches) <= KEEP_NAMES_PER_PARAGRAPH:
continue
replacements: list[tuple[int, int, str]] = []
kept = 0
for match in matches:
kept += 1
if kept <= KEEP_NAMES_PER_PARAGRAPH:
continue
prev = (_words_before(piece, match.start(), 1) or [""])[0]
form = _pronoun(gender, prev, _sentence_start(piece, match.start()))
replacements.append((match.start(), match.end(), form))
for start, end, form in reversed(replacements):
piece = piece[:start] + form + piece[end:]
out.append(piece)
return "".join(out)
def shape_journal(
title: str,
body: str,
user_bodies: list[str],
person_labels: list[str] | None = None,
) -> tuple[str, str]:
turns = _turns(user_bodies)
text = (body or "").strip()
heading = (title or "").strip()
text = _strip_roles(text)
if is_verbatim_join(text, turns) and len(turns) > 1:
text = "\n\n".join(turns)
else:
text = paragraphize(text)
if person_labels:
text = naturalize_person_mentions(text, person_labels, turns)
return heading, text

474
backend/journal_store.py Normal file
View File

@ -0,0 +1,474 @@
"""Journal Day, Draft, Entry and Space product store. Source messages stay in dialogue_store."""
from __future__ import annotations
import json
import re
import uuid
from datetime import datetime, timezone
from dialogue_store import (
StoreError,
create_conversation,
create_space,
get_space,
list_conversations_for_day,
list_spaces,
rename_space,
start_usage_session,
)
from db import _insert_source_refs, get_db, row_to_dict
from journal_policy import consolidation_offer, require_origin
from journal_body import clean_title, to_markdown
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
SCRATCH_MAX_ITEMS = 40
SCRATCH_MAX_TEXT = 400
def _now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def _parse_ids(raw: str | None) -> list[str]:
if not raw:
return []
try:
data = json.loads(raw)
except json.JSONDecodeError:
return []
if not isinstance(data, list):
return []
return [str(item) for item in data]
def _load_source_ids(conn, table: str, owner_col: str, owner_id: str, row: dict | None) -> tuple[list[str], list[str]]:
refs = conn.execute(
f"""
SELECT source_kind, source_id, sort_order
FROM {table}
WHERE {owner_col} = ?
ORDER BY source_kind, sort_order
""",
(owner_id,),
).fetchall()
conversations: list[str] = []
messages: list[str] = []
for item in refs:
kind = item["source_kind"]
source_id = item["source_id"]
if kind == "conversation":
conversations.append(source_id)
elif kind == "message":
messages.append(source_id)
if conversations or messages:
return conversations, messages
row = row or {}
return _parse_ids(row.get("source_conversation_ids")), _parse_ids(row.get("source_message_ids"))
def _attach_sources(conn, table: str, owner_col: str, owner_id: str, row: dict | None) -> dict | None:
if not row:
return None
conversations, messages = _load_source_ids(conn, table, owner_col, owner_id, row)
row["source_conversation_ids"] = conversations
row["source_message_ids"] = messages
row["title"] = clean_title(row.get("title") or "")
return row
def _owned(conn, table: str, record_id: str, profile_id: str) -> dict | None:
return row_to_dict(
conn.execute(
f"SELECT * FROM {table} WHERE id = ? AND profile_id = ?",
(record_id, profile_id),
).fetchone()
)
def _decode_draft(conn, row: dict | None) -> dict | None:
if not row:
return None
return _attach_sources(conn, "journal_draft_source_refs", "draft_id", row["id"], row)
def _decode_version(conn, row: dict | None) -> dict | None:
if not row:
return None
owner_id = row.get("current_version_id") or row.get("id")
return _attach_sources(conn, "journal_entry_version_source_refs", "version_id", owner_id, row)
def create_user_space(profile_id: str, title: str) -> dict:
if not (title or "").strip():
raise StoreError("empty_title", "Space-Name darf nicht leer sein")
return create_space(profile_id, title.strip(), visibility="user")
def list_user_spaces(profile_id: str) -> list[dict]:
return list_spaces(profile_id, visibility="user")
def get_user_space(profile_id: str, space_id: str) -> dict:
space = get_space(profile_id, space_id)
if space.get("visibility") != "user":
raise StoreError("not_found", "Reflection Space nicht gefunden", 404)
return space
def rename_user_space(profile_id: str, space_id: str, title: str) -> dict:
get_user_space(profile_id, space_id)
return rename_space(profile_id, space_id, title)
def get_or_create_day(profile_id: str, space_id: str, calendar_date: str) -> dict:
get_user_space(profile_id, space_id)
if not DATE_RE.match(calendar_date or ""):
raise StoreError("invalid_date", "calendar_date muss YYYY-MM-DD sein")
with get_db() as conn:
existing = row_to_dict(
conn.execute(
"""
SELECT * FROM journal_days
WHERE profile_id = ? AND space_id = ? AND calendar_date = ?
""",
(profile_id, space_id, calendar_date),
).fetchone()
)
if existing:
return existing
day_id = str(uuid.uuid4())
conn.execute(
"""
INSERT INTO journal_days (id, profile_id, space_id, calendar_date)
VALUES (?, ?, ?, ?)
""",
(day_id, profile_id, space_id, calendar_date),
)
return row_to_dict(conn.execute("SELECT * FROM journal_days WHERE id = ?", (day_id,)).fetchone())
def get_day(profile_id: str, journal_day_id: str) -> dict:
with get_db() as conn:
day = _owned(conn, "journal_days", journal_day_id, profile_id)
if not day:
raise StoreError("not_found", "Journal Day nicht gefunden", 404)
return day
def list_days(profile_id: str, space_id: str) -> list[dict]:
get_user_space(profile_id, space_id)
with get_db() as conn:
rows = conn.execute(
"""
SELECT d.*,
(SELECT COUNT(*) FROM conversations c
WHERE c.journal_day_id = d.id AND c.profile_id = d.profile_id) AS conversation_count,
(SELECT COUNT(*) FROM journal_entries e
WHERE e.journal_day_id = d.id AND e.profile_id = d.profile_id AND e.deleted_at IS NULL
) AS entry_count
FROM journal_days d
WHERE d.profile_id = ? AND d.space_id = ?
ORDER BY d.calendar_date DESC
""",
(profile_id, space_id),
).fetchall()
result = []
for row in rows:
item = row_to_dict(row)
entry = current_entries(profile_id, item["id"])
item["entry_title"] = (entry[-1].get("title") if entry else "") or ""
item["entry_id"] = entry[-1]["id"] if entry else None
result.append(item)
return result
def create_day_conversation(profile_id: str, journal_day_id: str, title: str = "") -> dict:
day = get_day(profile_id, journal_day_id)
usage = start_usage_session(profile_id, intent="journal")
return create_conversation(
profile_id,
usage_session_id=usage["id"],
title=title or "Gespräch",
space_id=day["space_id"],
journal_day_id=day["id"],
)
def current_draft(profile_id: str, journal_day_id: str) -> dict | None:
get_day(profile_id, journal_day_id)
with get_db() as conn:
row = row_to_dict(
conn.execute(
"""
SELECT * FROM journal_drafts
WHERE profile_id = ? AND journal_day_id = ? AND superseded_at IS NULL
ORDER BY created DESC
LIMIT 1
""",
(profile_id, journal_day_id),
).fetchone()
)
return _decode_draft(conn, row)
def insert_draft(
profile_id: str,
journal_day_id: str,
title: str,
body: str,
source_conversation_ids: list[str],
source_message_ids: list[str],
) -> dict:
get_day(profile_id, journal_day_id)
draft_id = str(uuid.uuid4())
as_of = _now()
with get_db() as conn:
conn.execute(
"""
UPDATE journal_drafts
SET superseded_at = ?
WHERE profile_id = ? AND journal_day_id = ? AND superseded_at IS NULL
""",
(as_of, profile_id, journal_day_id),
)
conn.execute(
"""
INSERT INTO journal_drafts
(id, profile_id, journal_day_id, title, body,
source_conversation_ids, source_message_ids, as_of)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
draft_id,
profile_id,
journal_day_id,
clean_title(title),
body or "",
"[]",
"[]",
as_of,
),
)
_insert_source_refs(
conn,
"journal_draft_source_refs",
"draft_id",
draft_id,
profile_id,
source_conversation_ids or [],
source_message_ids or [],
)
row = row_to_dict(conn.execute("SELECT * FROM journal_drafts WHERE id = ?", (draft_id,)).fetchone())
return _decode_draft(conn, row)
def current_entries(profile_id: str, journal_day_id: str) -> list[dict]:
with get_db() as conn:
rows = conn.execute(
"""
SELECT e.*, v.title, v.body, v.origin, v.source_conversation_ids, v.source_message_ids, v.created AS version_created
FROM journal_entries e
LEFT JOIN journal_entry_versions v ON v.id = e.current_version_id
WHERE e.profile_id = ? AND e.journal_day_id = ? AND e.deleted_at IS NULL
ORDER BY e.created
""",
(profile_id, journal_day_id),
).fetchall()
return [_decode_version(conn, row_to_dict(row)) for row in rows]
def get_entry(profile_id: str, entry_id: str, include_deleted: bool = False) -> dict:
with get_db() as conn:
entry = _owned(conn, "journal_entries", entry_id, profile_id)
if not entry:
raise StoreError("not_found", "Journal Entry nicht gefunden", 404)
if entry.get("deleted_at") and not include_deleted:
raise StoreError("not_found", "Journal Entry nicht gefunden", 404)
version = None
if entry.get("current_version_id"):
version = _decode_version(
conn,
row_to_dict(
conn.execute(
"SELECT * FROM journal_entry_versions WHERE id = ? AND profile_id = ?",
(entry["current_version_id"], profile_id),
).fetchone()
),
)
entry["version"] = version
if version:
entry["title"] = version.get("title") or ""
entry["body"] = version.get("body") or ""
entry["origin"] = version.get("origin")
entry["source_conversation_ids"] = version.get("source_conversation_ids") or []
entry["source_message_ids"] = version.get("source_message_ids") or []
return entry
def list_versions(profile_id: str, entry_id: str) -> list[dict]:
get_entry(profile_id, entry_id, include_deleted=True)
with get_db() as conn:
rows = conn.execute(
"""
SELECT * FROM journal_entry_versions
WHERE profile_id = ? AND entry_id = ?
ORDER BY created
""",
(profile_id, entry_id),
).fetchall()
return [_decode_version(conn, row_to_dict(row)) for row in rows]
def save_entry(
profile_id: str,
journal_day_id: str,
title: str,
body: str,
origin: str,
entry_id: str | None = None,
source_conversation_ids: list[str] | None = None,
source_message_ids: list[str] | None = None,
) -> dict:
require_origin(origin)
day = get_day(profile_id, journal_day_id)
version_id = str(uuid.uuid4())
with get_db() as conn:
if entry_id:
entry = _owned(conn, "journal_entries", entry_id, profile_id)
if not entry or entry.get("deleted_at"):
raise StoreError("not_found", "Journal Entry nicht gefunden", 404)
if entry["journal_day_id"] != journal_day_id:
raise StoreError("assignment_mismatch", "Entry gehört zu einem anderen Journal Day")
else:
entry_id = str(uuid.uuid4())
conn.execute(
"""
INSERT INTO journal_entries (id, profile_id, journal_day_id, space_id)
VALUES (?, ?, ?, ?)
""",
(entry_id, profile_id, journal_day_id, day["space_id"]),
)
conn.execute(
"""
INSERT INTO journal_entry_versions
(id, profile_id, entry_id, title, body, source_conversation_ids, source_message_ids, origin, created)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
version_id,
profile_id,
entry_id,
clean_title(title),
to_markdown(body or ""),
"[]",
"[]",
origin,
_now(),
),
)
_insert_source_refs(
conn,
"journal_entry_version_source_refs",
"version_id",
version_id,
profile_id,
source_conversation_ids or [],
source_message_ids or [],
)
conn.execute(
"""
UPDATE journal_entries
SET current_version_id = ?, updated = datetime('now'), deleted_at = NULL
WHERE id = ? AND profile_id = ?
""",
(version_id, entry_id, profile_id),
)
return get_entry(profile_id, entry_id)
def restore_entry(profile_id: str, entry_id: str, version_id: str) -> dict:
entry = get_entry(profile_id, entry_id)
with get_db() as conn:
version = row_to_dict(
conn.execute(
"""
SELECT * FROM journal_entry_versions
WHERE id = ? AND entry_id = ? AND profile_id = ?
""",
(version_id, entry_id, profile_id),
).fetchone()
)
if not version:
raise StoreError("not_found", "Version nicht gefunden", 404)
decoded = _decode_version(conn, version)
return save_entry(
profile_id,
entry["journal_day_id"],
decoded.get("title") or "",
decoded.get("body") or "",
origin="restore",
entry_id=entry_id,
source_conversation_ids=decoded.get("source_conversation_ids") or [],
source_message_ids=decoded.get("source_message_ids") or [],
)
def soft_delete_entry(profile_id: str, entry_id: str) -> dict:
get_entry(profile_id, entry_id)
with get_db() as conn:
conn.execute(
"UPDATE journal_entries SET deleted_at = ?, updated = datetime('now') WHERE id = ? AND profile_id = ?",
(_now(), entry_id, profile_id),
)
return {"id": entry_id, "deleted": True}
def _parse_scratch(raw) -> list[dict]:
if isinstance(raw, list):
data = raw
else:
try:
data = json.loads(raw or "[]")
except json.JSONDecodeError:
data = []
if not isinstance(data, list):
return []
items: list[dict] = []
for item in data[:SCRATCH_MAX_ITEMS]:
if not isinstance(item, dict):
continue
text = str(item.get("text") or "")[:SCRATCH_MAX_TEXT]
items.append(
{
"id": str(item.get("id") or uuid.uuid4()),
"text": text,
"done": bool(item.get("done")),
}
)
return items
def save_day_scratch(profile_id: str, journal_day_id: str, items: list[dict]) -> list[dict]:
get_day(profile_id, journal_day_id)
cleaned = _parse_scratch(items)
with get_db() as conn:
conn.execute(
"UPDATE journal_days SET scratch_json = ?, updated = datetime('now') WHERE id = ? AND profile_id = ?",
(json.dumps(cleaned, ensure_ascii=False), journal_day_id, profile_id),
)
return cleaned
def day_payload(profile_id: str, journal_day_id: str) -> dict:
day = get_day(profile_id, journal_day_id)
space = get_user_space(profile_id, day["space_id"])
conversations = list_conversations_for_day(profile_id, journal_day_id)
return {
"day": {key: value for key, value in day.items() if key != "scratch_json"},
"space": space,
"conversations": conversations,
"current_draft": current_draft(profile_id, journal_day_id),
"entries": current_entries(profile_id, journal_day_id),
"scratch": _parse_scratch(day.get("scratch_json")),
"consolidation_offer": consolidation_offer(conversations),
}

View File

@ -1,9 +1,13 @@
from env_loader import load_env_file
load_env_file()
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from db import init_db
import data_layer_dialogue # noqa: F401
from routers import admin, auth, dialogue, placeholders, prompts, subscription, users
from routers import admin, auth, dialogue, journal, placeholders, prompts, subscription, users
from version import APP_VERSION
app = FastAPI(title="Kanshō", version=APP_VERSION)
@ -19,6 +23,7 @@ app.add_middleware(
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(dialogue.router)
app.include_router(journal.router)
app.include_router(prompts.router)
app.include_router(placeholders.router)
app.include_router(subscription.router)

208
backend/media_store.py Normal file
View File

@ -0,0 +1,208 @@
"""Local image and video attachments for journal entries.
Files never leave the trusted zone. Images have EXIF/text chunks stripped.
Video containers are stored as-is; GPS in video metadata is not stripped yet.
"""
from __future__ import annotations
import uuid
from pathlib import Path
from db import DATA_DIR, get_db, row_to_dict
from dialogue_store import StoreError
from journal_store import get_entry
IMAGE_TYPES = {
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
}
VIDEO_TYPES = {
"video/mp4": ".mp4",
"video/webm": ".webm",
"video/quicktime": ".mov",
}
ALLOWED_TYPES = {**IMAGE_TYPES, **VIDEO_TYPES}
IMAGE_MAX_BYTES = 8 * 1024 * 1024
VIDEO_MAX_BYTES = 64 * 1024 * 1024
MEDIA_ROOT = DATA_DIR / "media"
MAX_BYTES = IMAGE_MAX_BYTES # tests and older imports
def _owned(conn, table: str, record_id: str, profile_id: str) -> dict | None:
return row_to_dict(
conn.execute(f"SELECT * FROM {table} WHERE id = ? AND profile_id = ?", (record_id, profile_id)).fetchone()
)
def media_kind(content_type: str) -> str:
kind = (content_type or "").lower()
if kind in VIDEO_TYPES or kind.startswith("video/"):
return "video"
return "image"
def public_media(row: dict | None) -> dict | None:
if not row:
return None
out = dict(row)
out["kind"] = media_kind(row.get("content_type") or "")
return out
def sniff_content_type(data: bytes, declared: str) -> str:
declared = (declared or "").lower().split(";")[0].strip()
if data.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if data[:3] == b"\xff\xd8\xff":
return "image/jpeg"
if data[:6] in {b"GIF87a", b"GIF89a"}:
return "image/gif"
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "image/webp"
if len(data) >= 12 and data[4:8] == b"ftyp":
brand = data[8:12]
if brand in {b"qt ", b"moov"}:
return "video/quicktime"
return "video/mp4"
if data[:4] == b"\x1a\x45\xdf\xa3":
return "video/webm"
if declared in ALLOWED_TYPES:
return declared
return declared
def strip_jpeg_exif(data: bytes) -> bytes:
if len(data) < 4 or data[0:2] != b"\xff\xd8":
return data
out = bytearray(b"\xff\xd8")
i = 2
while i + 1 < len(data):
if data[i] != 0xFF:
out.extend(data[i:])
break
marker = data[i + 1]
if marker == 0xDA:
out.extend(data[i:])
break
if marker == 0xD9:
out.extend(data[i : i + 2])
break
if marker in {0xD0, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0x01}:
out.extend(data[i : i + 2])
i += 2
continue
if i + 4 > len(data):
break
length = int.from_bytes(data[i + 2 : i + 4], "big")
block_end = i + 2 + length
if marker == 0xE1 and data[i + 4 : i + 10] == b"Exif\x00\x00":
i = block_end
continue
out.extend(data[i:block_end])
i = block_end
return bytes(out)
def strip_png_metadata(data: bytes) -> bytes:
signature = b"\x89PNG\r\n\x1a\n"
if not data.startswith(signature):
return data
out = bytearray(signature)
drop = {b"eXIf", b"eXif", b"tEXt", b"zTXt", b"iTXt", b"tIME"}
i = 8
while i + 12 <= len(data):
length = int.from_bytes(data[i : i + 4], "big")
chunk_type = data[i + 4 : i + 8]
end = i + 12 + length
if chunk_type not in drop:
out.extend(data[i:end])
if chunk_type == b"IEND":
break
i = end
return bytes(out)
def prepare_image(data: bytes, content_type: str) -> bytes:
if content_type in {"image/jpeg", "image/jpg"}:
return strip_jpeg_exif(data)
if content_type == "image/png":
return strip_png_metadata(data)
return data
def save_media(profile_id: str, entry_id: str, filename: str, content_type: str, data: bytes) -> dict:
entry = get_entry(profile_id, entry_id)
if not data:
raise StoreError("empty_media", "Datei ist leer")
kind = sniff_content_type(data, content_type)
if kind not in ALLOWED_TYPES:
raise StoreError("unsupported_media", "Nur Bilder (JPEG, PNG, GIF, WebP) und Videos (MP4, WebM, MOV) sind erlaubt")
is_video = kind in VIDEO_TYPES
limit = VIDEO_MAX_BYTES if is_video else IMAGE_MAX_BYTES
if len(data) > limit:
label = "Video" if is_video else "Bild"
mb = limit // (1024 * 1024)
raise StoreError("media_too_large", f"{label} ist größer als {mb} MB")
payload = data if is_video else prepare_image(data, kind)
asset_id = str(uuid.uuid4())
ext = ALLOWED_TYPES[kind]
rel_path = f"{profile_id}/{asset_id}{ext}"
dest = MEDIA_ROOT / rel_path
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(payload)
prefix = "video" if is_video else "image"
safe_name = Path(filename or f"{prefix}{ext}").name
with get_db() as conn:
conn.execute(
"""
INSERT INTO media_assets
(id, profile_id, entry_id, entry_version_id, filename, content_type, rel_path)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(asset_id, profile_id, entry_id, entry.get("current_version_id"), safe_name, kind, rel_path),
)
row = row_to_dict(conn.execute("SELECT * FROM media_assets WHERE id = ?", (asset_id,)).fetchone())
return public_media(row)
def save_image(profile_id: str, entry_id: str, filename: str, content_type: str, data: bytes) -> dict:
return save_media(profile_id, entry_id, filename, content_type, data)
def list_media(profile_id: str, entry_id: str) -> list[dict]:
get_entry(profile_id, entry_id)
with get_db() as conn:
rows = conn.execute(
"""
SELECT * FROM media_assets
WHERE profile_id = ? AND entry_id = ?
ORDER BY created
""",
(profile_id, entry_id),
).fetchall()
return [public_media(row_to_dict(row)) for row in rows]
def get_media(profile_id: str, media_id: str) -> tuple[dict, Path]:
with get_db() as conn:
row = _owned(conn, "media_assets", media_id, profile_id)
if not row:
raise StoreError("not_found", "Medium nicht gefunden", 404)
path = MEDIA_ROOT / row["rel_path"]
if not path.is_file():
raise StoreError("not_found", "Mediendatei fehlt", 404)
return public_media(row), path
def delete_media(profile_id: str, media_id: str) -> dict:
row, path = get_media(profile_id, media_id)
with get_db() as conn:
conn.execute("DELETE FROM media_assets WHERE id = ? AND profile_id = ?", (media_id, profile_id))
try:
path.unlink(missing_ok=True)
except OSError:
pass
return {"id": media_id, "deleted": True}

View File

@ -0,0 +1,82 @@
"""MVP context keys. Values come from the Context Builder, never from Python prompt strings."""
from __future__ import annotations
from placeholders import Placeholder, register
def _from_ctx(key: str):
return lambda ctx: (ctx or {}).get(key) or ""
register(
Placeholder(
key="dialogue_context",
description="Vom Context Builder zusammengestellter Dialog- und Space-Kontext.",
data_class="B",
resolver=_from_ctx("dialogue_context"),
)
)
register(
Placeholder(
key="writing_profile",
description="Aktueller Writing-Profile-Brief für Journal-Artefakte. Kein KI-Draft, keine Dialogsteuerung.",
data_class="B",
resolver=_from_ctx("writing_profile"),
)
)
register(
Placeholder(
key="interaction_hint",
description="Interaction Profile plus aktueller Dialogue State. Kein Writing Profile, keine Ableitung aus Nicht-Widersprechen.",
data_class="C",
resolver=_from_ctx("interaction_hint"),
)
)
register(
Placeholder(
key="space_title",
description="Titel des aktuellen Reflection Space.",
data_class="C",
resolver=_from_ctx("space_title"),
)
)
register(
Placeholder(
key="existing_text",
description="Bestehender Draft oder Entry, nur bei ausdrücklichem Regenerieren.",
data_class="B",
resolver=_from_ctx("existing_text"),
)
)
register(
Placeholder(
key="source_text",
description="Klartext für Entity Detection. Verlässt die Trusted Zone nur über den Detect-Provider.",
data_class="B",
resolver=_from_ctx("source_text"),
)
)
register(
Placeholder(
key="known_labels",
description="Bereits gemappte lokale Labels, damit Detection sie nicht neu vorschlägt.",
data_class="C",
resolver=_from_ctx("known_labels"),
)
)
register(
Placeholder(
key="register_hint",
description="Lokales Erzählregister für den Dialogzug. Kein zweiter Modellaufruf.",
data_class="C",
resolver=_from_ctx("register_hint"),
)
)
register(
Placeholder(
key="review_package",
description="Gebündeltes Profile-Review-Paket (derselbe Vertrag für API und Copy/Paste).",
data_class="B",
resolver=_from_ctx("review_package"),
)
)

View File

@ -1,10 +1,21 @@
"""Local privacy gateway stub. Personal LLM egress is not allowed to skip this layer."""
"""Local privacy gateway. Personal generative egress is not allowed to skip this layer."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
import re
from entity_detect import detect_and_remember
from identity_store import KINSHIP, is_maskable_label, list_mappings
from pronoun_bind import bind_user_lines
from providers import ProviderError, complete_chat, generate_provider
MAX_EGRESS_CHARS = 24000
debug_last: dict[str, Any] | None = None
debug_calls = 0
class PrivacyGatewayError(Exception):
def __init__(self, code: str, message: str, status_code: int = 503):
@ -20,6 +31,7 @@ class GatewayRequest:
purpose: str
data_class: str
payload: dict[str, Any] = field(default_factory=dict)
profile_id: str | None = None
@dataclass
@ -35,31 +47,264 @@ ALLOWED_CLASSES = {"A", "B", "C"}
LOCAL_ONLY_CLASS = "A"
def reset_debug() -> None:
global debug_last, debug_calls
debug_last = None
debug_calls = 0
def inspect(request: GatewayRequest) -> GatewayResult:
"""Fail closed. No provider is configured in the local frame."""
"""Fail closed unless a policy-complete generative provider is configured."""
data_class = (request.data_class or "").upper()
if data_class not in ALLOWED_CLASSES:
return GatewayResult(
allowed=False,
reason="unknown_data_class",
)
return GatewayResult(allowed=False, reason="unknown_data_class")
if data_class == LOCAL_ONLY_CLASS:
return GatewayResult(
allowed=False,
reason="class_a_never_leaves_local_zone",
return GatewayResult(allowed=False, reason="class_a_never_leaves_local_zone")
config = generate_provider()
if not config:
return GatewayResult(allowed=False, reason="no_egress_provider_configured")
return GatewayResult(allowed=True, reason="policy_ok", provider=config.name)
def _minimize(text: str) -> str:
cleaned = (text or "").strip()
if len(cleaned) <= MAX_EGRESS_CHARS:
return cleaned
return cleaned[:MAX_EGRESS_CHARS]
_LETTER = r"A-Za-zÄÖÜäöüß"
_WORD = re.compile(rf"[{_LETTER}]+")
THING_GOVERNORS = {
"esse", "essen", "isst", "", "aßest", "gegessen",
"koche", "kochen", "kochte", "gekocht",
"trinke", "trinken", "trank", "getrunken",
"kaufe", "kaufen", "kaufte", "gekauft",
"bestelle", "bestellen", "bestellte",
"hole", "holen", "holte",
}
def _words_before(text: str, index: int, n: int = 4) -> list[str]:
return [word.lower() for word in _WORD.findall(text[:index])[-n:]]
def _words_after(text: str, index: int, n: int = 3) -> list[str]:
return [word.lower() for word in _WORD.findall(text[index:])[:n]]
def _is_person_token(token: str) -> bool:
raw = (token or "").strip().upper()
raw = raw[2:-2] if raw.startswith("[[") and raw.endswith("]]") else raw
return raw.startswith("PERSON:")
def _mask_person_hit(text: str, start: int, end: int) -> bool:
prev = _words_before(text, start)
nxt = _words_after(text, end)
if prev and prev[-1] in KINSHIP:
return True
if any(word in THING_GOVERNORS for word in prev + nxt):
return False
return True
def _mask_body(text: str, mappings: list[dict]) -> str:
masked = text
for item in sorted(mappings, key=lambda row: len(row.get("local_label") or ""), reverse=True):
label = (item.get("local_label") or "").strip()
token = (item.get("token") or "").strip()
if not label or not token or not is_maskable_label(label):
continue
placeholder = token if token.startswith("[[") else f"[[{token}]]"
pattern = re.compile(
rf"(?<![{_LETTER}]){re.escape(label)}(?![{_LETTER}])",
re.IGNORECASE,
)
return GatewayResult(
allowed=False,
reason="no_egress_provider_configured",
person = _is_person_token(token)
def repl(match: re.Match, *, _person=person, _ph=placeholder) -> str:
if _person and not _mask_person_hit(masked, match.start(), match.end()):
return match.group(0)
return _ph
masked = pattern.sub(repl, masked)
return masked
def _mask(text: str, mappings: list[dict], *, personal_lines_only: bool = False) -> str:
if not personal_lines_only:
return _mask_body(text, mappings)
parts: list[str] = []
for line in (text or "").splitlines(keepends=True):
raw = line[:-1] if line.endswith("\n") else line
newline = "\n" if line.endswith("\n") else ""
if raw.startswith("user:") or raw.startswith("assistant:"):
parts.append(_mask_body(raw, mappings) + newline)
else:
parts.append(line)
return "".join(parts)
def mask_for_egress(rendered: str, mappings: list[dict], purpose: str) -> str:
"""Mask names. Bind pronouns to person tokens only in the dialogue turn.
Journal generation keeps sie/er/ihr so demask does not turn every reference into the name.
"""
masked = _mask(rendered, mappings, personal_lines_only=purpose == "dialogue_turn")
if purpose == "dialogue_turn":
return bind_user_lines(masked)
return masked
def _demask(text: str, mappings: list[dict]) -> str:
result = text or ""
for item in mappings:
label = (item.get("local_label") or "").strip()
token = (item.get("token") or "").strip()
if not label or not token:
continue
placeholder = token if token.startswith("[[") else f"[[{token}]]"
result = result.replace(placeholder, label)
return result
def _validate_response(content: str, mappings: list[dict] | None = None) -> str:
text = (content or "").strip()
if not text:
raise PrivacyGatewayError("empty_provider_response", "Der Provider lieferte keine Antwort.")
leaked: list[str] = []
for item in mappings or []:
label = (item.get("local_label") or "").strip()
if not label or not is_maskable_label(label):
continue
if re.search(
rf"(?<![{_LETTER}]){re.escape(label)}(?![{_LETTER}])",
text,
re.IGNORECASE,
):
leaked.append(label)
if leaked:
raise PrivacyGatewayError(
"response_validation_failed",
"Antwort enthielt Klartext-Identität vor der Demaskierung.",
)
return text
def _fake_complete(purpose: str, rendered: str) -> str:
if purpose == "profile_review":
return (
'{"kind":"kansho.profile_review_result","format_version":1,"target":"writing",'
'"changes":[{"layer":"trait","key":"dry_humor","slug":"dry_humor","facet_key":"autobiographical_journal",'
'"action":"create","label":"Trockener Humor",'
'"proposed_value":"gelegentlich trocken, nie aufgesetzt",'
'"rationale":"Fake-Review: Journal-Evidenz trägt Humor in autobiographical_journal, nicht als globalen Core.",'
'"evidence_ids":[],"exemplars":[{"excerpt":"haha das war irgendwie lustig","role":"exemplar"}]}]}'
)
if purpose == "journal_generate":
lines = []
for line in rendered.splitlines():
if line.startswith("user:"):
lines.append(line[5:].strip())
body = " ".join(part for part in lines if part) or "Ein stiller Tag."
return f"Ein Tag\n\n{body}"
if "Keine Interviewfrage" in rendered:
return '{"operation":"fortfuehren","impulse":"Der letzte Faden bleibt offen."}'
impulse = (
"Was davon möchtest du vertiefen, ohne Unerwähntes als nicht geschehen zu behandeln?"
if ("nicht geschehen" in rendered or "nicht erwähnt" in rendered)
else "Was davon möchtest du festhalten?"
)
return '{"operation":"erleben_vertiefen","impulse":"' + impulse + '"}'
def complete_model(messages: list[dict], policy: dict[str, Any]) -> str:
config = generate_provider()
if not config:
raise PrivacyGatewayError("no_egress_provider_configured", "Es ist kein Egress-Provider konfiguriert.")
if config.mode == "fake":
rendered = "\n".join(item.get("content") or "" for item in messages)
return _fake_complete(policy.get("purpose") or "", rendered)
try:
return complete_chat(config, messages, timeout=60)
except ProviderError as exc:
raise PrivacyGatewayError(exc.code, exc.message, exc.status_code) from exc
def complete(request: GatewayRequest) -> GatewayResult:
global debug_last, debug_calls
result = inspect(request)
if not result.allowed:
raise PrivacyGatewayError(
result.reason,
"Persönlicher KI-Aufruf wurde vom Privacy Gateway blockiert. "
"Es ist kein Egress-Provider konfiguriert.",
"Es ist kein Egress-Provider konfiguriert."
if result.reason == "no_egress_provider_configured"
else "Persönlicher KI-Aufruf wurde vom Privacy Gateway blockiert.",
)
rendered = _minimize(str((request.payload or {}).get("rendered") or ""))
source_text = str((request.payload or {}).get("source_text") or rendered)
detect_name = None
try:
mappings, detect_name, detect_note = detect_and_remember(request.profile_id, source_text)
except ProviderError as exc:
raise PrivacyGatewayError(exc.code, exc.message, exc.status_code) from exc
if not mappings and request.profile_id:
mappings = list_mappings(request.profile_id)
masked = mask_for_egress(rendered, mappings, request.purpose)
debug_calls += 1
config = generate_provider()
debug_last = {
"purpose": request.purpose,
"layer": {
"journal_generate": "journalentwurf",
"profile_review": "profilreview",
}.get(request.purpose, "dialogzug"),
"data_class": request.data_class,
"prompt_slug": (request.payload or {}).get("prompt_slug"),
"rendered": rendered,
"masked": masked,
"mask_input": source_text,
"provider": result.provider,
"model": config.model if config else None,
"detect_provider": detect_name,
"detect_note": detect_note,
"mapping_count": len(mappings),
}
raw = complete_model(
[{"role": "user", "content": masked}],
{"purpose": request.purpose, "zdr": True, "no_train": True},
)
validated = _validate_response(raw, mappings)
result.content = _demask(validated, mappings)
result.allowed = True
debug_last["raw"] = validated
debug_last["reply"] = result.content
return result
def last_trace() -> dict | None:
return debug_last
def public_trace(trace: dict | None) -> dict | None:
"""Admin test view. No mapping table, no secrets."""
if not trace:
return None
return {
"purpose": trace.get("purpose"),
"layer": trace.get("layer"),
"data_class": trace.get("data_class"),
"prompt_slug": trace.get("prompt_slug"),
"provider": trace.get("provider"),
"model": trace.get("model"),
"detect_provider": trace.get("detect_provider"),
"detect_note": trace.get("detect_note"),
"mapping_count": trace.get("mapping_count"),
"intern": trace.get("rendered"),
"egress": trace.get("masked"),
"mask_input": trace.get("mask_input"),
"raw": trace.get("raw"),
"reply": trace.get("reply"),
}

View File

@ -0,0 +1,97 @@
"""Portable JSON documents for Writing and Interaction profiles. Not a journal backup."""
from __future__ import annotations
from datetime import datetime, timezone
from dialogue_store import StoreError
FORMAT_VERSION = 1
KIND_WRITING = "kansho.writing_profile"
KIND_INTERACTION = "kansho.interaction_profile"
KIND_BUNDLE = "kansho.profiles"
ORIGIN_WRITING = {"inferred", "manual", "imported", "accepted_suggestion", "initial_build"}
ORIGIN_INTERACTION = {"explicit", "manual", "accepted_suggestion"}
def exported_at() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def as_document(raw) -> dict:
if isinstance(raw, str):
import json
try:
raw = json.loads(raw)
except json.JSONDecodeError as exc:
raise StoreError("invalid_document", "JSON ist ungültig.") from exc
if not isinstance(raw, dict):
raise StoreError("invalid_document", "Profil-Dokument muss ein JSON-Objekt sein.")
version = raw.get("format_version", FORMAT_VERSION)
try:
version = int(version)
except (TypeError, ValueError) as exc:
raise StoreError("unsupported_format", "format_version fehlt oder ist ungültig.") from exc
if version != FORMAT_VERSION:
raise StoreError("unsupported_format", f"format_version {version} wird nicht unterstützt.")
return raw
def writing_payload(document: dict) -> dict:
data = as_document(document)
kind = (data.get("kind") or "").strip()
if kind == KIND_BUNDLE:
nested = data.get("writing")
if not isinstance(nested, dict):
raise StoreError("invalid_document", "Sammeldokument enthält kein Writing Profile.")
return nested
if kind != KIND_WRITING:
raise StoreError("invalid_document", "Das ist kein Writing-Profile-Dokument.")
return data
def interaction_payload(document: dict) -> dict:
data = as_document(document)
kind = (data.get("kind") or "").strip()
if kind == KIND_BUNDLE:
nested = data.get("interaction")
if not isinstance(nested, dict):
raise StoreError("invalid_document", "Sammeldokument enthält kein Interaction Profile.")
return nested
if kind != KIND_INTERACTION:
raise StoreError("invalid_document", "Das ist kein Interaction-Profile-Dokument.")
return data
def export_bundle(profile_id: str) -> dict:
from interaction_profile_store import export_document as export_interaction
from writing_profile_store import export_document as export_writing
writing = export_writing(profile_id)
interaction = export_interaction(profile_id)
writing.pop("kind", None)
writing.pop("format_version", None)
writing.pop("exported_at", None)
interaction.pop("kind", None)
interaction.pop("format_version", None)
interaction.pop("exported_at", None)
return {
"kind": KIND_BUNDLE,
"format_version": FORMAT_VERSION,
"exported_at": exported_at(),
"writing": writing,
"interaction": interaction,
}
def restore_bundle(profile_id: str, document: dict) -> dict:
data = as_document(document)
kind = (data.get("kind") or "").strip()
if kind != KIND_BUNDLE:
raise StoreError("invalid_document", "Das ist kein Sammeldokument beider Profile.")
from interaction_profile_store import restore_document as restore_interaction
from writing_profile_store import restore_document as restore_writing
writing = restore_writing(profile_id, data)
interaction = restore_interaction(profile_id, data)
return {"writing": writing, "interaction": interaction}

1119
backend/profile_review.py Normal file

File diff suppressed because it is too large Load Diff

120
backend/pronoun_bind.py Normal file
View File

@ -0,0 +1,120 @@
"""Bind German 3rd-person pronouns to the last named person. Local; no new Detect tokens."""
from __future__ import annotations
import re
from dataclasses import dataclass
PLACEHOLDER = re.compile(r"\[\[(PERSON:[^\]]+)\]\]")
PIECE = re.compile(r"\[\[PERSON:[^\]]+\]\]|[A-Za-zÄÖÜäöüß]+")
FEM_CUES = {
"frau", "partnerin", "tochter", "mutter", "schwester", "freundin",
"oma", "tante",
}
MASC_CUES = {
"herr", "mann", "partner", "sohn", "vater", "bruder", "freund",
"opa", "onkel",
}
FEM_PRON = {"sie", "ihr", "ihre", "ihrem", "ihren", "ihrer", "ihres"}
MASC_PRON = {"er", "ihn", "ihm", "seine", "seinem", "seinen", "seiner", "seines"}
IHR_ADDRESS = {
"seid", "habt", "wart", "könnt", "müsst", "sollt", "wollt", "dürft",
"hättet", "wäret", "werdet",
}
PLURAL_VERB = {
"sind", "waren", "haben", "hatten", "werden", "wurden",
"können", "müssen", "wollen", "sollen",
}
@dataclass
class BindState:
last: str | None = None
last_f: str | None = None
last_m: str | None = None
def _gender_from_prev(pieces: list[re.Match], index: int) -> str | None:
seen: list[str] = []
pos = index - 1
while pos >= 0 and len(seen) < 3:
raw = pieces[pos].group(0)
if PLACEHOLDER.fullmatch(raw):
break
seen.append(raw.lower())
pos -= 1
window = set(seen)
if window & FEM_CUES:
return "f"
if window & MASC_CUES:
return "m"
return None
def _referent(state: BindState, kind: str) -> str | None:
if kind == "f" and state.last_f:
return state.last_f
if kind == "m" and state.last_m:
return state.last_m
return state.last
def _is_address_ihr(prev: str, nxt: str) -> bool:
if nxt in IHR_ADDRESS or prev in IHR_ADDRESS:
return True
if prev.endswith("t") and len(prev) >= 4:
return True
return False
def bind_pronouns(text: str, state: BindState | None = None) -> tuple[str, BindState]:
state = state or BindState()
pieces = list(PIECE.finditer(text or ""))
replacements: list[tuple[int, int, str]] = []
for i, match in enumerate(pieces):
raw = match.group(0)
person = PLACEHOLDER.fullmatch(raw)
if person:
token = person.group(1)
state.last = token
gender = _gender_from_prev(pieces, i)
if gender == "f":
state.last_f = token
elif gender == "m":
state.last_m = token
continue
low = raw.lower()
prev = pieces[i - 1].group(0).lower() if i else ""
nxt = pieces[i + 1].group(0).lower() if i + 1 < len(pieces) else ""
if low == "ihr" and _is_address_ihr(prev, nxt):
continue
if low == "sie" and nxt in PLURAL_VERB:
continue
kind = "f" if low in FEM_PRON else "m" if low in MASC_PRON else ""
if not kind:
continue
ref = _referent(state, kind)
if not ref:
continue
replacements.append((match.start(), match.end(), f"[[{ref}]]"))
if not replacements:
return text or "", state
out = text or ""
for start, end, repl in reversed(replacements):
out = out[:start] + repl + out[end:]
return out, state
def bind_user_lines(text: str) -> str:
"""Only user: lines; state carries across those lines. Instructions stay untouched."""
state = BindState()
chunks: list[str] = []
for line in (text or "").splitlines(keepends=True):
ended = line.endswith("\n")
body = line[:-1] if ended else line
if body.startswith("user:"):
bound, state = bind_pronouns(body[5:], state)
chunks.append("user:" + bound + ("\n" if ended else ""))
else:
chunks.append(line)
return "".join(chunks)

View File

@ -0,0 +1,153 @@
"""Admin-configurable generate/detect endpoints. Keys stay in env, never in the DB."""
from __future__ import annotations
import json
import os
from pathlib import Path
from urllib.parse import urlparse
from db import get_db
from env_loader import allows_remote_plaintext_detect, runtime_env, upsert_env_value
SEED_PATH = Path(__file__).resolve().parent / "config" / "providers.seed.json"
ROLES = ("generate", "detect")
_KEY_ENV = {
"generate": "KANSHO_PROVIDER_KEY",
"detect": "KANSHO_DETECT_PROVIDER_KEY",
}
# Interne Rollen bleiben generate/detect. Fachlich: Sprachmodell vs. Maskierung.
ROLE_META = {
"generate": {
"title": "Sprachmodell",
"task": "Dialogzug und Journalentwurf. Nur maskierter Kontext. Wählt die Operation im Dialogzug mit.",
},
"detect": {
"title": "Maskierung",
"task": "Eigener Detect-Endpunkt. Nur Namen, keine Operation. Externes Klartext-Detect nur Development/Test. Produktiv: lokal oder Muster.",
},
}
class SettingsError(Exception):
def __init__(self, code: str, message: str, status_code: int = 400):
super().__init__(message)
self.code = code
self.message = message
self.status_code = status_code
def seed_provider_settings(conn) -> None:
items = json.loads(SEED_PATH.read_text(encoding="utf-8"))
for item in items:
role = item["role"]
if role not in ROLES:
continue
existing = conn.execute("SELECT role FROM provider_settings WHERE role = ?", (role,)).fetchone()
if existing:
continue
conn.execute(
"""
INSERT INTO provider_settings (role, name, url, model, zdr, no_train)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
role,
item.get("name") or "",
item.get("url") or "",
item.get("model") or "",
1 if item.get("zdr", True) else 0,
1 if item.get("no_train", True) else 0,
),
)
def get_setting(role: str) -> dict | None:
if role not in ROLES:
return None
with get_db() as conn:
row = conn.execute("SELECT * FROM provider_settings WHERE role = ?", (role,)).fetchone()
return dict(row) if row else None
def upsert_setting(role: str, *, name: str, url: str, model: str, zdr: bool, no_train: bool) -> dict:
if role not in ROLES:
raise SettingsError("unknown_provider_role", "Unbekannte Provider-Rolle.")
url = (url or "").strip()
model = (model or "").strip()
name = (name or "").strip()
if role == "generate" and not url:
raise SettingsError("provider_url_required", "Das Sprachmodell braucht eine URL.")
if url:
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise SettingsError("provider_url_invalid", "URL muss http(s) sein.")
if role == "generate" and not model:
raise SettingsError("provider_model_required", "Das Sprachmodell braucht ein Modell.")
with get_db() as conn:
conn.execute(
"""
INSERT INTO provider_settings (role, name, url, model, zdr, no_train, updated)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(role) DO UPDATE SET
name = excluded.name,
url = excluded.url,
model = excluded.model,
zdr = excluded.zdr,
no_train = excluded.no_train,
updated = datetime('now')
""",
(role, name, url, model, 1 if zdr else 0, 1 if no_train else 0),
)
return get_setting(role) or {}
def set_role_key(role: str, key: str) -> None:
if role not in ROLES:
raise SettingsError("unknown_provider_role", "Unbekannte Provider-Rolle.")
value = (key or "").strip()
if not value:
raise SettingsError("provider_key_empty", "Leerer Key wird nicht gespeichert.")
upsert_env_value(_KEY_ENV[role], value)
def public_role_status(role: str) -> dict:
from providers import _is_local_url, detect_provider, generate_provider
row = get_setting(role) or {}
config = generate_provider() if role == "generate" else detect_provider()
local = _is_local_url(row.get("url") or "")
own_key = bool((os.environ.get(_KEY_ENV[role]) or "").strip())
generate_key = bool((os.environ.get(_KEY_ENV["generate"]) or "").strip())
if own_key:
key_source = "own"
elif role == "detect" and generate_key and not local:
key_source = "generate"
else:
key_source = "none"
meta = ROLE_META.get(role, {})
return {
"role": role,
"title": meta.get("title") or role,
"task": meta.get("task") or "",
"name": row.get("name") or (config.name if config else ""),
"url": row.get("url") or "",
"model": row.get("model") or "",
"zdr": bool(row.get("zdr", 1)),
"no_train": bool(row.get("no_train", 1)),
"local": local,
"key_present": key_source != "none",
"key_source": key_source,
"ready": config is not None,
"mode": config.mode if config else "unconfigured",
"remote_plaintext_detect": bool(config and not config.local and config.mode == "http" and role == "detect"),
"remote_plaintext_allowed": allows_remote_plaintext_detect() if role == "detect" else None,
"updated": row.get("updated") or "",
}
def public_status() -> dict:
return {
"roles": [public_role_status(role) for role in ROLES],
"runtime_env": runtime_env(),
"note": "Zwei Einträge: Maskierung (Detect) und Sprachmodell. Externes Klartext-Detect nur Development/Test. Produktiv lokal oder Muster. Keys nur in backend/.env.",
}

171
backend/providers.py Normal file
View File

@ -0,0 +1,171 @@
"""Two independently configured OpenAI-compatible providers: generate vs detect."""
from __future__ import annotations
import os
from dataclasses import dataclass
from urllib.parse import urlparse
import httpx
from env_loader import load_env_file
load_env_file()
class ProviderError(Exception):
def __init__(self, code: str, message: str, status_code: int = 503):
super().__init__(message)
self.code = code
self.message = message
self.status_code = status_code
@dataclass(frozen=True)
class ProviderConfig:
role: str
name: str
mode: str
url: str
model: str
key: str
local: bool
zdr: bool
no_train: bool
def _truthy(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
def _is_local_url(url: str) -> bool:
host = (urlparse(url).hostname or "").lower()
return host in {"localhost", "127.0.0.1", "::1", "ollama"}
def _setting(role: str) -> dict:
try:
from provider_settings import get_setting
return get_setting(role) or {}
except Exception:
return {}
def _text(env_name: str, db_value: str | None, default: str = "") -> str:
env = (os.environ.get(env_name) or "").strip()
if env:
return env
if db_value:
return str(db_value).strip()
return default
def _flag(env_name: str, db_value, default: bool = True) -> bool:
if (os.environ.get(env_name) or "").strip():
return _truthy(env_name)
if db_value is None:
return default
return bool(int(db_value) if not isinstance(db_value, bool) else db_value)
def generate_provider() -> ProviderConfig | None:
if _truthy("KANSHO_FAKE_PROVIDER"):
return ProviderConfig(
role="generate",
name="fake",
mode="fake",
url="",
model="fake",
key="",
local=True,
zdr=True,
no_train=True,
)
row = _setting("generate")
key = (os.environ.get("KANSHO_PROVIDER_KEY") or "").strip()
url = _text("KANSHO_PROVIDER_URL", row.get("url"), "https://openrouter.ai/api/v1/chat/completions")
model = _text("KANSHO_PROVIDER_MODEL", row.get("model"), "openai/gpt-4o-mini")
local = _is_local_url(url)
zdr = local or _flag("KANSHO_PROVIDER_ZDR", row.get("zdr"), True)
no_train = local or _flag("KANSHO_PROVIDER_NO_TRAIN", row.get("no_train"), True)
if not local and not key:
return None
if not local and (not zdr or not no_train):
return None
return ProviderConfig(
role="generate",
name=row.get("name") or ("ollama" if local else "openrouter"),
mode="http",
url=url,
model=model,
key=key,
local=local,
zdr=zdr,
no_train=no_train,
)
def detect_provider() -> ProviderConfig | None:
if _truthy("KANSHO_FAKE_DETECT"):
return ProviderConfig(
role="detect",
name="fake-detect",
mode="fake",
url="",
model="fake",
key="",
local=True,
zdr=True,
no_train=True,
)
row = _setting("detect")
url = _text("KANSHO_DETECT_PROVIDER_URL", row.get("url"), "")
if not url:
return None
model = _text("KANSHO_DETECT_PROVIDER_MODEL", row.get("model"), "openai/gpt-4.1-nano")
key = (os.environ.get("KANSHO_DETECT_PROVIDER_KEY") or "").strip()
local = _is_local_url(url)
if not key and not local:
key = (os.environ.get("KANSHO_PROVIDER_KEY") or "").strip()
zdr = local or _flag("KANSHO_DETECT_ZDR", row.get("zdr"), True)
no_train = local or _flag("KANSHO_DETECT_NO_TRAIN", row.get("no_train"), True)
if not local and not key:
return None
if not local and (not zdr or not no_train):
return None
return ProviderConfig(
role="detect",
name=row.get("name") or ("ollama" if local else "openrouter"),
mode="http",
url=url,
model=model,
key=key,
local=local,
zdr=zdr,
no_train=no_train,
)
def complete_chat(config: ProviderConfig, messages: list[dict], *, timeout: float, max_tokens: int | None = None) -> str:
payload: dict = {"model": config.model, "messages": messages}
if max_tokens is not None:
payload["max_tokens"] = max_tokens
if not config.local:
payload["provider"] = {"data_collection": "deny"}
headers = {"Content-Type": "application/json"}
if config.key:
headers["Authorization"] = f"Bearer {config.key}"
if not config.local:
headers["HTTP-Referer"] = "https://kansho.local"
headers["X-Title"] = "Kansho"
try:
response = httpx.post(config.url, json=payload, headers=headers, timeout=timeout)
except httpx.HTTPError as exc:
raise ProviderError("provider_unreachable", f"{config.role}-Provider ist nicht erreichbar.") from exc
if response.status_code >= 400:
raise ProviderError("provider_rejected", f"{config.role}-Provider hat die Anfrage abgelehnt.")
data = response.json()
try:
return data["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as exc:
raise ProviderError("provider_shape", f"Unerwartete {config.role}-Antwort.") from exc

View File

@ -2,3 +2,4 @@ fastapi==0.115.6
uvicorn[standard]==0.34.0
bcrypt==4.2.1
httpx==0.28.1
python-multipart==0.0.20

175
backend/retrieval.py Normal file
View File

@ -0,0 +1,175 @@
"""Swappable retrieval interface. Specs name what to select; recency/SQL is implementation."""
from __future__ import annotations
from typing import Any
from db import get_db, row_to_dict
from journal_body import plain_text
# Implementation caps. Not part of the selection-spec contract.
_DAY_MESSAGE_CAP = 80
_SPACE_ENTRY_LIMIT = 5
_SPACE_ENTRY_EXCERPT_CHARS = 400
_SPACE_SOURCE_CONV_LIMIT = 3
_SPACE_SOURCE_EXCERPT_CHARS = 400
_SPACE_SOURCE_MESSAGE_CAP = 8
def retrieve(profile_id: str, spec: dict[str, Any]) -> list[dict]:
kind = spec.get("kind")
if kind == "day_messages":
return _day_messages(profile_id, spec)
if kind == "space_entries":
return _space_entries(profile_id, spec)
if kind == "space_recent_sources":
return _space_recent_sources(profile_id, spec)
if kind == "writing_profile":
return _writing_profile(profile_id)
return []
def _day_messages(profile_id: str, spec: dict[str, Any]) -> list[dict]:
journal_day_id = spec.get("journal_day_id")
conversation_ids = spec.get("conversation_ids") or []
if not journal_day_id:
return []
with get_db() as conn:
if conversation_ids:
placeholders = ",".join("?" * len(conversation_ids))
rows = conn.execute(
f"""
SELECT m.*, c.title AS conversation_title
FROM messages m
JOIN conversations c ON c.id = m.conversation_id
WHERE m.profile_id = ? AND c.journal_day_id = ? AND m.conversation_id IN ({placeholders})
ORDER BY c.created, m.seq
LIMIT ?
""",
(profile_id, journal_day_id, *conversation_ids, _DAY_MESSAGE_CAP),
).fetchall()
else:
rows = conn.execute(
"""
SELECT m.*, c.title AS conversation_title
FROM messages m
JOIN conversations c ON c.id = m.conversation_id
WHERE m.profile_id = ? AND c.journal_day_id = ?
ORDER BY c.created, m.seq
LIMIT ?
""",
(profile_id, journal_day_id, _DAY_MESSAGE_CAP),
).fetchall()
return [row_to_dict(row) for row in rows]
def _space_entries(profile_id: str, spec: dict[str, Any]) -> list[dict]:
space_id = spec.get("space_id")
exclude_day_id = spec.get("exclude_day_id")
if not space_id:
return []
params: list[Any] = [profile_id, space_id]
exclude_sql = ""
if exclude_day_id:
exclude_sql = "AND e.journal_day_id != ?"
params.append(exclude_day_id)
params.append(_SPACE_ENTRY_LIMIT)
with get_db() as conn:
rows = conn.execute(
f"""
SELECT e.id AS entry_id, e.journal_day_id, d.calendar_date, v.title, v.body, v.origin
FROM journal_entries e
JOIN journal_days d ON d.id = e.journal_day_id
JOIN journal_entry_versions v ON v.id = e.current_version_id
WHERE e.profile_id = ? AND e.space_id = ? AND e.deleted_at IS NULL {exclude_sql}
ORDER BY d.calendar_date DESC, e.updated DESC
LIMIT ?
""",
params,
).fetchall()
result = []
for row in rows:
item = row_to_dict(row)
item["excerpt"] = plain_text(item.get("body") or "")[:_SPACE_ENTRY_EXCERPT_CHARS]
result.append(item)
return result
def _space_recent_sources(profile_id: str, spec: dict[str, Any]) -> list[dict]:
"""Recent original conversations in the same space. Recency is the current heuristic."""
space_id = spec.get("space_id")
exclude_conversation_id = spec.get("exclude_conversation_id")
exclude_journal_day_id = spec.get("exclude_journal_day_id")
if not space_id:
return []
params: list[Any] = [profile_id, space_id]
extra = ""
if exclude_conversation_id:
extra += " AND c.id != ?"
params.append(exclude_conversation_id)
if exclude_journal_day_id:
extra += " AND (c.journal_day_id IS NULL OR c.journal_day_id != ?)"
params.append(exclude_journal_day_id)
params.append(_SPACE_SOURCE_CONV_LIMIT)
with get_db() as conn:
conversations = conn.execute(
f"""
SELECT c.id, c.updated, c.journal_day_id
FROM conversations c
WHERE c.profile_id = ? AND c.space_id = ? {extra}
ORDER BY c.updated DESC, c.created DESC
LIMIT ?
""",
params,
).fetchall()
result = []
for conv in conversations:
messages = conn.execute(
"""
SELECT body FROM messages
WHERE profile_id = ? AND conversation_id = ? AND role = 'user'
ORDER BY seq
LIMIT ?
""",
(profile_id, conv["id"], _SPACE_SOURCE_MESSAGE_CAP),
).fetchall()
excerpt = " ".join((row["body"] or "").strip() for row in messages if (row["body"] or "").strip())
excerpt = excerpt[:_SPACE_SOURCE_EXCERPT_CHARS]
if not excerpt:
continue
result.append(
{
"conversation_id": conv["id"],
"journal_day_id": conv["journal_day_id"],
"excerpt": excerpt,
}
)
return result
def _writing_profile(profile_id: str) -> list[dict]:
with get_db() as conn:
row = row_to_dict(
conn.execute(
"SELECT compiled_brief, updated FROM writing_profiles WHERE profile_id = ?",
(profile_id,),
).fetchone()
)
sources = [
row_to_dict(item)
for item in conn.execute(
"""
SELECT id, kind, body, entry_id, weight, created
FROM writing_profile_sources
WHERE profile_id = ?
ORDER BY CASE kind
WHEN 'journal_entry' THEN 0
WHEN 'imported_text' THEN 1
ELSE 2
END, created DESC
""",
(profile_id,),
).fetchall()
]
if not row and not sources:
return []
return [{"compiled_brief": (row or {}).get("compiled_brief") or "", "sources": sources}]

View File

@ -1,8 +1,10 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from auth import require_admin_dep
from db import get_db
from dialogue_store import StoreError, get_conversation, inventory, list_conversations, list_derived_for_conversation, list_messages
from provider_settings import ROLES, SettingsError, public_status, set_role_key, upsert_setting
from version import APP_VERSION, BUILD_DATE, MODULE_VERSIONS
router = APIRouter(prefix="/api/admin", tags=["admin"])
@ -15,6 +17,7 @@ def admin_health(session: dict = Depends(require_admin_dep)):
prompts = conn.execute("SELECT COUNT(*) AS n FROM ai_prompts").fetchone()["n"]
features = conn.execute("SELECT COUNT(*) AS n FROM features").fetchone()["n"]
dialogue = inventory()
providers = public_status()
return {
"ok": True,
"service": "kansho",
@ -26,6 +29,10 @@ def admin_health(session: dict = Depends(require_admin_dep)):
"prompts": prompts,
"features": features,
"dialogue": dialogue,
"providers": {
role["role"]: {"ready": role["ready"], "key_present": role["key_present"]}
for role in providers["roles"]
},
},
"session": {
"profile_id": session["profile_id"],
@ -54,3 +61,37 @@ def admin_conversation(conversation_id: str, session: dict = Depends(require_adm
}
except StoreError as exc:
raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": exc.message}) from exc
class ProviderUpdate(BaseModel):
name: str = ""
url: str = ""
model: str = ""
zdr: bool = True
no_train: bool = True
key: str | None = Field(default=None, description="Write-only. Never returned.")
@router.get("/providers")
def admin_providers(session: dict = Depends(require_admin_dep)):
return public_status()
@router.put("/providers/{role}")
def admin_update_provider(role: str, body: ProviderUpdate, session: dict = Depends(require_admin_dep)):
if role not in ROLES:
raise HTTPException(status_code=404, detail={"code": "unknown_provider_role", "message": "Unbekannte Provider-Rolle."})
try:
upsert_setting(
role,
name=body.name,
url=body.url,
model=body.model,
zdr=body.zdr,
no_train=body.no_train,
)
if body.key is not None and body.key.strip():
set_role_key(role, body.key)
except SettingsError as exc:
raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": exc.message}) from exc
return public_status()

View File

@ -7,10 +7,13 @@ from auth import require_auth
from context_builder import build_internal_context
from continuity import checkpoint_usage_session, close_usage_session
from derived_kinds import catalog
from dialogue_turn import run_turn, visible_for_role
from engine import EngineError
from dialogue_store import (
StoreError,
append_message,
create_conversation,
delete_conversation,
create_handoff,
create_space,
create_thread,
@ -27,7 +30,7 @@ from dialogue_store import (
router = APIRouter(prefix="/api/dialogue", tags=["dialogue"])
def _http(exc: StoreError):
def _http(exc: StoreError | EngineError):
raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": exc.message}) from exc
@ -38,6 +41,7 @@ class SessionWrite(BaseModel):
class ConversationWrite(BaseModel):
usage_session_id: str | None = None
title: str = ""
space_id: str | None = None
class MessageWrite(BaseModel):
@ -102,7 +106,12 @@ def checkpoint_session(usage_session_id: str, session: dict = Depends(require_au
@router.post("/conversations")
def post_conversation(req: ConversationWrite, session: dict = Depends(require_auth)):
try:
return create_conversation(session["profile_id"], req.usage_session_id, req.title)
return create_conversation(
session["profile_id"],
req.usage_session_id,
req.title,
space_id=req.space_id,
)
except StoreError as exc:
_http(exc)
@ -112,6 +121,18 @@ def get_conversations(session: dict = Depends(require_auth)):
return list_conversations(session["profile_id"])
@router.delete("/conversations/{conversation_id}")
def remove_conversation(conversation_id: str, session: dict = Depends(require_auth)):
try:
from writing_profile_store import remember_dialogue_style
result = delete_conversation(session["profile_id"], conversation_id)
remember_dialogue_style(session["profile_id"])
return result
except StoreError as exc:
_http(exc)
@router.get("/conversations/{conversation_id}")
def get_one_conversation(conversation_id: str, session: dict = Depends(require_auth)):
try:
@ -139,6 +160,17 @@ def post_message(conversation_id: str, req: MessageWrite, session: dict = Depend
_http(exc)
@router.post("/conversations/{conversation_id}/turn")
def conversation_turn(conversation_id: str, req: MessageWrite, session: dict = Depends(require_auth)):
try:
return visible_for_role(
run_turn(session["profile_id"], conversation_id, req.body, message_id=req.id),
session.get("role"),
)
except (StoreError, EngineError) as exc:
_http(exc)
@router.get("/conversations/{conversation_id}/messages")
def get_messages(conversation_id: str, session: dict = Depends(require_auth)):
try:

604
backend/routers/journal.py Normal file
View File

@ -0,0 +1,604 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from auth import require_auth
from dialogue_store import StoreError, delete_conversation, get_conversation, list_messages
from dialogue_turn import run_turn, visible_for_role
from privacy_gateway import GatewayRequest, inspect
from engine import EngineError
from journal_generate import generate_draft
from journal_policy import PolicyError
from journal_store import (
create_day_conversation,
create_user_space,
day_payload,
get_day,
get_entry,
get_or_create_day,
get_user_space,
list_days,
list_user_spaces,
list_versions,
rename_user_space,
restore_entry,
save_day_scratch,
save_entry,
soft_delete_entry,
)
from media_store import delete_media, get_media, list_media, save_media
from writing_profile_store import (
accept_suggestion as accept_writing_suggestion,
export_document as export_writing_document,
get_profile,
import_corpus,
list_corpus,
rebuild_brief,
reject_suggestion as reject_writing_suggestion,
remember_dialogue_style,
remember_journal_entry,
restore_document as restore_writing_document,
set_governance as set_writing_governance,
update_facet,
update_trait,
)
from profile_review import (
ingest_import,
ingest_journal,
import_result as import_review_result,
open_paste_review,
review_status,
run_api_review,
)
from interaction_profile_store import (
accept_suggestion as accept_interaction_suggestion,
export_document as export_interaction_document,
get_profile as get_interaction_profile,
reject_suggestion as reject_interaction_suggestion,
restore_document as restore_interaction_document,
set_governance as set_interaction_governance,
upsert_preference,
)
from profile_documents import export_bundle, restore_bundle
router = APIRouter(prefix="/api/journal", tags=["journal"])
def _http(exc: StoreError | PolicyError | EngineError):
raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": exc.message}) from exc
class SpaceWrite(BaseModel):
title: str = Field(min_length=1)
class DayWrite(BaseModel):
calendar_date: str = Field(min_length=10, max_length=10)
class ConversationWrite(BaseModel):
title: str = ""
class TurnWrite(BaseModel):
body: str = Field(min_length=1)
id: str | None = None
class GenerateWrite(BaseModel):
conversation_ids: list[str] | None = None
include_existing: bool = False
class EntryWrite(BaseModel):
journal_day_id: str
title: str = ""
body: str = ""
entry_id: str | None = None
origin: str = "user_edit"
source_conversation_ids: list[str] = Field(default_factory=list)
source_message_ids: list[str] = Field(default_factory=list)
class RestoreWrite(BaseModel):
version_id: str
class ScratchItem(BaseModel):
id: str | None = None
text: str = ""
done: bool = False
class ScratchWrite(BaseModel):
items: list[ScratchItem] = Field(default_factory=list)
class ImportWrite(BaseModel):
body: str = Field(min_length=1)
occurred_at: str | None = None
context_hint: str | None = None
class CorpusWrite(BaseModel):
items: list[dict]
class TraitWrite(BaseModel):
statement: str | None = None
locked: bool | None = None
facet_key: str | None = None
label: str | None = None
class GovernanceWrite(BaseModel):
governance: str
class FacetWrite(BaseModel):
value: str | None = None
locked: bool | None = None
class PreferenceWrite(BaseModel):
scope: str = "global"
pref_key: str
value: str
locked: bool | None = None
origin: str = "manual"
class ReviewImportWrite(BaseModel):
result: dict | str
review_id: str | None = None
@router.get("/spaces")
def spaces(session: dict = Depends(require_auth)):
return list_user_spaces(session["profile_id"])
@router.post("/spaces")
def create_space(body: SpaceWrite, session: dict = Depends(require_auth)):
try:
return create_user_space(session["profile_id"], body.title)
except StoreError as exc:
_http(exc)
@router.get("/spaces/{space_id}")
def space_detail(space_id: str, session: dict = Depends(require_auth)):
try:
space = get_user_space(session["profile_id"], space_id)
return {"space": space, "days": list_days(session["profile_id"], space_id)}
except StoreError as exc:
_http(exc)
@router.patch("/spaces/{space_id}")
def space_rename(space_id: str, body: SpaceWrite, session: dict = Depends(require_auth)):
try:
return rename_user_space(session["profile_id"], space_id, body.title)
except StoreError as exc:
_http(exc)
@router.post("/spaces/{space_id}/days")
def open_day(space_id: str, body: DayWrite, session: dict = Depends(require_auth)):
try:
day = get_or_create_day(session["profile_id"], space_id, body.calendar_date)
return day_payload(session["profile_id"], day["id"])
except StoreError as exc:
_http(exc)
@router.get("/days/{journal_day_id}")
def read_day(journal_day_id: str, session: dict = Depends(require_auth)):
try:
return day_payload(session["profile_id"], journal_day_id)
except StoreError as exc:
_http(exc)
@router.patch("/days/{journal_day_id}/scratch")
def write_scratch(journal_day_id: str, body: ScratchWrite, session: dict = Depends(require_auth)):
try:
return {
"scratch": save_day_scratch(
session["profile_id"],
journal_day_id,
[{"id": item.id, "text": item.text, "done": item.done} for item in body.items],
)
}
except StoreError as exc:
_http(exc)
@router.post("/days/{journal_day_id}/conversations")
def start_conversation(journal_day_id: str, body: ConversationWrite, session: dict = Depends(require_auth)):
try:
return create_day_conversation(session["profile_id"], journal_day_id, body.title)
except StoreError as exc:
_http(exc)
@router.delete("/conversations/{conversation_id}")
def remove_conversation(conversation_id: str, session: dict = Depends(require_auth)):
try:
result = delete_conversation(session["profile_id"], conversation_id)
remember_dialogue_style(session["profile_id"])
return result
except StoreError as exc:
_http(exc)
@router.get("/conversations/{conversation_id}")
def read_conversation(conversation_id: str, session: dict = Depends(require_auth)):
try:
conv = get_conversation(session["profile_id"], conversation_id)
return {"conversation": conv, "messages": list_messages(session["profile_id"], conversation_id)}
except StoreError as exc:
_http(exc)
@router.get("/egress-status")
def egress_status(session: dict = Depends(require_auth)):
result = inspect(GatewayRequest(prompt_id=None, purpose="dialogue_turn", data_class="B"))
return {"ready": result.allowed, "reason": result.reason, "provider": result.provider}
@router.post("/conversations/{conversation_id}/turn")
def conversation_turn(conversation_id: str, body: TurnWrite, session: dict = Depends(require_auth)):
try:
return visible_for_role(
run_turn(session["profile_id"], conversation_id, body.body, message_id=body.id),
session.get("role"),
)
except (StoreError, EngineError) as exc:
_http(exc)
@router.post("/days/{journal_day_id}/generate")
def generate(journal_day_id: str, body: GenerateWrite, session: dict = Depends(require_auth)):
try:
return visible_for_role(
generate_draft(
session["profile_id"],
journal_day_id,
conversation_ids=body.conversation_ids,
include_existing=body.include_existing,
explicit=True,
),
session.get("role"),
)
except (StoreError, PolicyError, EngineError) as exc:
_http(exc)
@router.post("/entries")
def write_entry(body: EntryWrite, session: dict = Depends(require_auth)):
try:
get_day(session["profile_id"], body.journal_day_id)
entry = save_entry(
session["profile_id"],
body.journal_day_id,
body.title,
body.body,
origin=body.origin,
entry_id=body.entry_id,
source_conversation_ids=body.source_conversation_ids,
source_message_ids=body.source_message_ids,
)
remember_journal_entry(
session["profile_id"],
entry["id"],
entry.get("body") or "",
origin=body.origin,
)
ingest_journal(
session["profile_id"],
entry["id"],
entry.get("body") or "",
origin=body.origin,
)
return entry
except (StoreError, PolicyError) as exc:
_http(exc)
@router.get("/entries/{entry_id}")
def read_entry(entry_id: str, session: dict = Depends(require_auth)):
try:
entry = get_entry(session["profile_id"], entry_id)
entry["media"] = list_media(session["profile_id"], entry_id)
entry["versions"] = list_versions(session["profile_id"], entry_id)
return entry
except StoreError as exc:
_http(exc)
@router.get("/entries/{entry_id}/versions")
def entry_versions(entry_id: str, session: dict = Depends(require_auth)):
try:
return list_versions(session["profile_id"], entry_id)
except StoreError as exc:
_http(exc)
@router.post("/entries/{entry_id}/restore")
def entry_restore(entry_id: str, body: RestoreWrite, session: dict = Depends(require_auth)):
try:
entry = restore_entry(session["profile_id"], entry_id, body.version_id)
remember_journal_entry(
session["profile_id"],
entry["id"],
entry.get("body") or "",
origin=entry.get("origin"),
)
ingest_journal(
session["profile_id"],
entry["id"],
entry.get("body") or "",
origin=entry.get("origin"),
)
return entry
except StoreError as exc:
_http(exc)
@router.delete("/entries/{entry_id}")
def entry_delete(entry_id: str, session: dict = Depends(require_auth)):
try:
return soft_delete_entry(session["profile_id"], entry_id)
except StoreError as exc:
_http(exc)
@router.post("/entries/{entry_id}/media")
async def entry_media(entry_id: str, file: UploadFile = File(...), session: dict = Depends(require_auth)):
try:
data = await file.read()
return save_media(
session["profile_id"],
entry_id,
file.filename or "media",
file.content_type or "",
data,
)
except StoreError as exc:
_http(exc)
@router.get("/media/{media_id}")
def read_media(media_id: str, session: dict = Depends(require_auth)):
try:
row, path = get_media(session["profile_id"], media_id)
return FileResponse(path, media_type=row["content_type"], filename=row["filename"])
except StoreError as exc:
_http(exc)
@router.delete("/media/{media_id}")
def remove_media(media_id: str, session: dict = Depends(require_auth)):
try:
return delete_media(session["profile_id"], media_id)
except StoreError as exc:
_http(exc)
@router.get("/writing-profile")
def writing_profile(session: dict = Depends(require_auth)):
return get_profile(session["profile_id"])
@router.patch("/writing-profile")
def writing_patch(body: GovernanceWrite, session: dict = Depends(require_auth)):
try:
return set_writing_governance(session["profile_id"], body.governance)
except StoreError as exc:
_http(exc)
@router.patch("/writing-profile/facets/{facet_key}")
def writing_facet(facet_key: str, body: FacetWrite, session: dict = Depends(require_auth)):
try:
return update_facet(
session["profile_id"],
facet_key,
value=body.value,
locked=body.locked,
)
except StoreError as exc:
_http(exc)
@router.patch("/writing-profile/traits/{slug}")
def writing_trait(slug: str, body: TraitWrite, session: dict = Depends(require_auth)):
try:
return update_trait(
session["profile_id"],
slug,
statement=body.statement,
locked=body.locked,
facet_key=body.facet_key,
label=body.label,
)
except StoreError as exc:
_http(exc)
@router.post("/writing-profile/suggestions/{suggestion_id}/accept")
def writing_accept(suggestion_id: str, session: dict = Depends(require_auth)):
try:
return accept_writing_suggestion(session["profile_id"], suggestion_id)
except StoreError as exc:
_http(exc)
@router.post("/writing-profile/suggestions/{suggestion_id}/reject")
def writing_reject(suggestion_id: str, session: dict = Depends(require_auth)):
try:
return reject_writing_suggestion(session["profile_id"], suggestion_id)
except StoreError as exc:
_http(exc)
@router.post("/writing-profile/import")
def writing_import(body: ImportWrite, session: dict = Depends(require_auth)):
try:
return ingest_import(
session["profile_id"],
body.body,
occurred_at=body.occurred_at,
context_hint=body.context_hint,
)
except StoreError as exc:
_http(exc)
@router.post("/writing-profile/corpus")
def writing_corpus(body: CorpusWrite, session: dict = Depends(require_auth)):
try:
return import_corpus(session["profile_id"], body.items)
except StoreError as exc:
_http(exc)
@router.get("/writing-profile/corpus")
def writing_corpus_list(session: dict = Depends(require_auth)):
return {"items": list_corpus(session["profile_id"])}
@router.post("/writing-profile/rebuild")
def writing_rebuild(session: dict = Depends(require_auth)):
return rebuild_brief(session["profile_id"])
@router.get("/writing-profile/review")
def writing_review_status(session: dict = Depends(require_auth)):
return review_status(session["profile_id"])
@router.post("/writing-profile/review/paste")
def writing_review_paste(session: dict = Depends(require_auth)):
try:
return open_paste_review(session["profile_id"])
except StoreError as exc:
_http(exc)
@router.post("/writing-profile/initial-build/paste")
def writing_initial_build_paste(session: dict = Depends(require_auth)):
try:
return open_paste_review(session["profile_id"], mode="initial_build")
except StoreError as exc:
_http(exc)
@router.post("/writing-profile/review/api")
def writing_review_api(session: dict = Depends(require_auth)):
try:
return run_api_review(session["profile_id"])
except (StoreError, EngineError) as exc:
_http(exc)
@router.post("/writing-profile/initial-build/api")
def writing_initial_build_api(session: dict = Depends(require_auth)):
try:
return run_api_review(session["profile_id"], mode="initial_build")
except (StoreError, EngineError) as exc:
_http(exc)
@router.post("/writing-profile/review/import")
def writing_review_import(body: ReviewImportWrite, session: dict = Depends(require_auth)):
try:
return import_review_result(session["profile_id"], body.result, review_id=body.review_id)
except StoreError as exc:
_http(exc)
@router.get("/writing-profile/export")
def writing_export(session: dict = Depends(require_auth)):
return export_writing_document(session["profile_id"])
@router.post("/writing-profile/restore")
def writing_restore(body: dict, session: dict = Depends(require_auth)):
try:
return restore_writing_document(session["profile_id"], body)
except StoreError as exc:
_http(exc)
@router.get("/interaction-profile")
def interaction_profile(session: dict = Depends(require_auth)):
return get_interaction_profile(session["profile_id"])
@router.patch("/interaction-profile")
def interaction_patch(body: GovernanceWrite, session: dict = Depends(require_auth)):
try:
return set_interaction_governance(session["profile_id"], body.governance)
except StoreError as exc:
_http(exc)
@router.patch("/interaction-profile/preferences")
def interaction_pref(body: PreferenceWrite, session: dict = Depends(require_auth)):
try:
return upsert_preference(
session["profile_id"],
scope=body.scope,
pref_key=body.pref_key,
value=body.value,
origin=body.origin if body.origin in {"explicit", "manual"} else "manual",
locked=body.locked,
)
except StoreError as exc:
_http(exc)
@router.post("/interaction-profile/suggestions/{suggestion_id}/accept")
def interaction_accept(suggestion_id: str, session: dict = Depends(require_auth)):
try:
return accept_interaction_suggestion(session["profile_id"], suggestion_id)
except StoreError as exc:
_http(exc)
@router.post("/interaction-profile/suggestions/{suggestion_id}/reject")
def interaction_reject(suggestion_id: str, session: dict = Depends(require_auth)):
try:
return reject_interaction_suggestion(session["profile_id"], suggestion_id)
except StoreError as exc:
_http(exc)
@router.get("/interaction-profile/export")
def interaction_export(session: dict = Depends(require_auth)):
return export_interaction_document(session["profile_id"])
@router.post("/interaction-profile/restore")
def interaction_restore(body: dict, session: dict = Depends(require_auth)):
try:
return restore_interaction_document(session["profile_id"], body)
except StoreError as exc:
_http(exc)
@router.get("/profiles/export")
def profiles_export(session: dict = Depends(require_auth)):
return export_bundle(session["profile_id"])
@router.post("/profiles/restore")
def profiles_restore(body: dict, session: dict = Depends(require_auth)):
try:
return restore_bundle(session["profile_id"], body)
except StoreError as exc:
_http(exc)

View File

@ -3,6 +3,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends
from auth import require_auth
import placeholder_mvp # noqa: F401
import placeholder_system # noqa: F401
from placeholders import list_placeholders
from privacy_placeholders import catalog as privacy_catalog

View File

@ -117,6 +117,19 @@ def update_prompt(prompt_id: str, req: PromptWrite, session: dict = Depends(requ
current = row_to_dict(conn.execute("SELECT id FROM ai_prompts WHERE id = ?", (prompt_id,)).fetchone())
if not current:
raise HTTPException(404, "Prompt nicht gefunden")
if req.prompt_type == "base" and req.template:
try:
preview_prompt(
{
"id": prompt_id,
"slug": req.slug.strip(),
"prompt_type": req.prompt_type,
"template": req.template,
},
{},
)
except EngineError as exc:
_http(exc)
conn.execute(
"""
UPDATE ai_prompts

View File

@ -100,6 +100,13 @@ CREATE TABLE IF NOT EXISTS conversations (
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
usage_session_id TEXT REFERENCES usage_sessions(id),
title TEXT NOT NULL DEFAULT '',
space_id TEXT,
journal_day_id TEXT,
narrative_mode TEXT NOT NULL DEFAULT '',
reflection_depth TEXT NOT NULL DEFAULT '',
current_focus TEXT NOT NULL DEFAULT '',
emotional_intensity TEXT NOT NULL DEFAULT '',
long_story INTEGER NOT NULL DEFAULT 0,
created TEXT NOT NULL DEFAULT (datetime('now')),
updated TEXT NOT NULL DEFAULT (datetime('now'))
);
@ -202,3 +209,237 @@ CREATE TABLE IF NOT EXISTS identity_mappings (
created TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (profile_id, token)
);
CREATE TABLE IF NOT EXISTS journal_days (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
calendar_date TEXT NOT NULL,
scratch_json TEXT NOT NULL DEFAULT '[]',
created TEXT NOT NULL DEFAULT (datetime('now')),
updated TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (space_id, calendar_date)
);
CREATE TABLE IF NOT EXISTS journal_drafts (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
journal_day_id TEXT NOT NULL REFERENCES journal_days(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
source_conversation_ids TEXT NOT NULL DEFAULT '[]',
source_message_ids TEXT NOT NULL DEFAULT '[]',
as_of TEXT NOT NULL,
superseded_at TEXT,
created TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS journal_entries (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
journal_day_id TEXT NOT NULL REFERENCES journal_days(id) ON DELETE CASCADE,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
current_version_id TEXT,
deleted_at TEXT,
created TEXT NOT NULL DEFAULT (datetime('now')),
updated TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS journal_entry_versions (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
entry_id TEXT NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
source_conversation_ids TEXT NOT NULL DEFAULT '[]',
source_message_ids TEXT NOT NULL DEFAULT '[]',
origin TEXT NOT NULL CHECK (origin IN ('user_edit', 'accepted_draft', 'restore')),
created TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS journal_draft_source_refs (
draft_id TEXT NOT NULL REFERENCES journal_drafts(id) ON DELETE CASCADE,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
source_kind TEXT NOT NULL CHECK (source_kind IN ('conversation', 'message')),
source_id TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (draft_id, source_kind, source_id)
);
CREATE TABLE IF NOT EXISTS journal_entry_version_source_refs (
version_id TEXT NOT NULL REFERENCES journal_entry_versions(id) ON DELETE CASCADE,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
source_kind TEXT NOT NULL CHECK (source_kind IN ('conversation', 'message')),
source_id TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (version_id, source_kind, source_id)
);
CREATE TABLE IF NOT EXISTS media_assets (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
entry_id TEXT NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE,
entry_version_id TEXT REFERENCES journal_entry_versions(id),
filename TEXT NOT NULL DEFAULT '',
content_type TEXT NOT NULL,
rel_path TEXT NOT NULL,
created TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS writing_profiles (
profile_id TEXT PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
compiled_brief TEXT NOT NULL DEFAULT '',
governance TEXT NOT NULL DEFAULT 'learning' CHECK (governance IN ('learning', 'advising', 'frozen')),
lifecycle TEXT NOT NULL DEFAULT 'uninitialized' CHECK (lifecycle IN ('uninitialized', 'initial_pending', 'confirmed')),
version INTEGER NOT NULL DEFAULT 0,
review_ready INTEGER NOT NULL DEFAULT 0,
last_reviewed TEXT,
updated TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS writing_profile_sources (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('imported_text', 'journal_entry', 'dialogue_style')),
body TEXT NOT NULL DEFAULT '',
entry_id TEXT REFERENCES journal_entries(id),
weight REAL NOT NULL DEFAULT 1,
occurred_at TEXT,
context_hint TEXT NOT NULL DEFAULT '',
created TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS writing_profile_facets (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
facet_key TEXT NOT NULL,
layer TEXT NOT NULL DEFAULT 'context' CHECK (layer IN ('core', 'context', 'output')),
value TEXT NOT NULL DEFAULT '',
evidence TEXT NOT NULL DEFAULT '',
origin TEXT NOT NULL DEFAULT 'manual' CHECK (origin IN ('manual', 'imported', 'accepted_suggestion', 'initial_build')),
locked INTEGER NOT NULL DEFAULT 0,
updated TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (profile_id, facet_key)
);
CREATE TABLE IF NOT EXISTS writing_profile_traits (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
facet_key TEXT NOT NULL,
slug TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
statement TEXT NOT NULL DEFAULT '',
origin TEXT NOT NULL DEFAULT 'manual' CHECK (origin IN ('manual', 'imported', 'accepted_suggestion', 'initial_build')),
locked INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'merged', 'split', 'retired')),
observed_from TEXT,
observed_to TEXT,
updated TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (profile_id, slug)
);
CREATE TABLE IF NOT EXISTS writing_profile_trait_refs (
id TEXT PRIMARY KEY,
trait_id TEXT NOT NULL REFERENCES writing_profile_traits(id) ON DELETE CASCADE,
source_id TEXT,
role TEXT NOT NULL CHECK (role IN ('evidence', 'exemplar')),
excerpt TEXT NOT NULL DEFAULT '',
occurred_at TEXT,
created TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS writing_profile_suggestions (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
facet_key TEXT NOT NULL DEFAULT '',
trait_slug TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL DEFAULT 'update',
proposed_value TEXT NOT NULL DEFAULT '',
evidence TEXT NOT NULL DEFAULT '',
payload_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected')),
created TEXT NOT NULL DEFAULT (datetime('now')),
resolved TEXT
);
CREATE TABLE IF NOT EXISTS interaction_profiles (
profile_id TEXT PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
governance TEXT NOT NULL DEFAULT 'advising' CHECK (governance IN ('learning', 'advising', 'frozen')),
updated TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS interaction_preferences (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
scope TEXT NOT NULL DEFAULT 'global',
pref_key TEXT NOT NULL,
value TEXT NOT NULL DEFAULT '',
evidence TEXT NOT NULL DEFAULT '',
origin TEXT NOT NULL DEFAULT 'manual' CHECK (origin IN ('explicit', 'manual', 'accepted_suggestion')),
locked INTEGER NOT NULL DEFAULT 0,
updated TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (profile_id, scope, pref_key)
);
CREATE TABLE IF NOT EXISTS interaction_suggestions (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
scope TEXT NOT NULL DEFAULT 'global',
pref_key TEXT NOT NULL,
proposed_value TEXT NOT NULL DEFAULT '',
evidence TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected')),
created TEXT NOT NULL DEFAULT (datetime('now')),
resolved TEXT
);
CREATE TABLE IF NOT EXISTS writing_profile_evidence (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
trigger TEXT NOT NULL CHECK (trigger IN (
'user_edit_of_draft', 'heuristic_drift', 'dialogue_sample', 'periodic_batch', 'explicit_review'
)),
source_kind TEXT NOT NULL DEFAULT '',
source_id TEXT,
excerpt TEXT NOT NULL DEFAULT '',
local_signals_json TEXT NOT NULL DEFAULT '{}',
example_for_json TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'bundled', 'consumed', 'dismissed')),
created TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS writing_profile_reviews (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
target TEXT NOT NULL DEFAULT 'writing' CHECK (target IN ('writing', 'interaction')),
channel TEXT NOT NULL CHECK (channel IN ('api', 'paste')),
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'proposed', 'applied', 'dismissed')),
package_json TEXT NOT NULL DEFAULT '{}',
result_json TEXT,
created TEXT NOT NULL DEFAULT (datetime('now')),
resolved TEXT
);
CREATE TABLE IF NOT EXISTS writing_profile_versions (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
seq INTEGER NOT NULL,
governance TEXT NOT NULL DEFAULT '',
lifecycle TEXT NOT NULL DEFAULT '',
facets_json TEXT NOT NULL DEFAULT '[]',
traits_json TEXT NOT NULL DEFAULT '[]',
compiled_brief TEXT NOT NULL DEFAULT '',
cause TEXT NOT NULL DEFAULT '',
created TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (profile_id, seq)
);
CREATE TABLE IF NOT EXISTS provider_settings (
role TEXT PRIMARY KEY CHECK (role IN ('generate', 'detect')),
name TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
zdr INTEGER NOT NULL DEFAULT 1,
no_train INTEGER NOT NULL DEFAULT 1,
updated TEXT NOT NULL DEFAULT (datetime('now'))
);

View File

@ -0,0 +1,266 @@
"""Architecture correction pass. Run from backend/: python tests/test_architecture_correction.py"""
from __future__ import annotations
import json
import os
import sys
import tempfile
import uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
os.environ["KANSHO_DB_PATH"] = str(Path(tempfile.gettempdir()) / "kansho-arch-correction-test.sqlite")
os.environ["KANSHO_FAKE_PROVIDER"] = "1"
os.environ["KANSHO_FAKE_DETECT"] = "1"
Path(os.environ["KANSHO_DB_PATH"]).unlink(missing_ok=True)
from fastapi.testclient import TestClient
from db import get_db, migrate_journal_source_refs
from main import app
from privacy_gateway import reset_debug
def expect(ok: bool, message: str) -> None:
if not ok:
raise SystemExit(f"FAIL: {message}")
print(f"OK {message}")
def header(token: str) -> dict:
return {"X-Auth-Token": token}
def main() -> None:
reset_debug()
with TestClient(app) as client:
setup = client.post(
"/api/auth/setup",
json={"email": "lars@example.test", "name": "Lars", "password": "test-pass"},
)
headers = header(setup.json()["token"])
profile_id = setup.json()["profile_id"]
space = client.post("/api/journal/spaces", headers=headers, json={"title": "Alltag"})
expect(space.status_code == 200, "space created")
space_id = space.json()["id"]
day = client.post(
f"/api/journal/spaces/{space_id}/days",
headers=headers,
json={"calendar_date": "2026-08-20"},
)
day_id = day.json()["day"]["id"]
conv = client.post(
f"/api/journal/days/{day_id}/conversations",
headers=headers,
json={"title": "Quelle"},
)
conv_id = conv.json()["id"]
turn = client.post(
f"/api/journal/conversations/{conv_id}/turn",
headers=headers,
json={"body": "Heute war der Markt voll. Ich habe Kirschen gekauft."},
)
expect(turn.status_code == 200, "source turn")
gen = client.post(
f"/api/journal/days/{day_id}/generate",
headers=headers,
json={"conversation_ids": [conv_id]},
)
expect(gen.status_code == 200, "draft from conversation")
draft = gen.json()
expect(draft["source_conversation_ids"] == [conv_id], "draft reconstructs conversation ids")
expect(len(draft["source_message_ids"]) >= 2, "draft reconstructs message ids")
with get_db() as conn:
refs = conn.execute(
"SELECT source_kind, source_id FROM journal_draft_source_refs WHERE draft_id = ?",
(draft["id"],),
).fetchall()
kinds = {row["source_kind"] for row in refs}
expect("conversation" in kinds and "message" in kinds, "draft provenance is relational")
saved = client.post(
"/api/journal/entries",
headers=headers,
json={
"journal_day_id": day_id,
"title": draft.get("title") or "Markttag",
"body": draft.get("body") or "Kirschen",
"origin": "accepted_draft",
"entry_id": None,
"source_conversation_ids": draft["source_conversation_ids"],
"source_message_ids": draft["source_message_ids"],
},
)
expect(saved.status_code == 200, f"save entry {saved.text}")
entry = saved.json()
expect(entry["source_conversation_ids"] == [conv_id], "entry version reconstructs conversation ids")
expect(entry["source_message_ids"] == draft["source_message_ids"], "entry version reconstructs message ids")
versions = client.get(f"/api/journal/entries/{entry['id']}/versions", headers=headers)
expect(versions.status_code == 200, "list versions")
expect(versions.json()[0]["source_conversation_ids"] == [conv_id], "version list keeps source links")
legacy_draft_id = str(uuid.uuid4())
legacy_version_id = str(uuid.uuid4())
legacy_entry_id = str(uuid.uuid4())
mid = draft["source_message_ids"][0]
with get_db() as conn:
conn.execute(
"""
INSERT INTO journal_drafts
(id, profile_id, journal_day_id, title, body,
source_conversation_ids, source_message_ids, as_of, superseded_at)
VALUES (?, ?, ?, 'Legacy', 'alt', ?, ?, datetime('now'), datetime('now'))
""",
(legacy_draft_id, profile_id, day_id, json.dumps([conv_id]), json.dumps([mid])),
)
conn.execute(
"""
INSERT INTO journal_entries (id, profile_id, journal_day_id, space_id)
VALUES (?, ?, ?, ?)
""",
(legacy_entry_id, profile_id, day_id, space_id),
)
conn.execute(
"""
INSERT INTO journal_entry_versions
(id, profile_id, entry_id, title, body, source_conversation_ids, source_message_ids, origin)
VALUES (?, ?, ?, 'Legacy', 'alt', ?, ?, 'user_edit')
""",
(legacy_version_id, profile_id, legacy_entry_id, json.dumps([conv_id]), json.dumps([mid])),
)
migrate_journal_source_refs(conn)
draft_refs = [
(row["source_kind"], row["source_id"])
for row in conn.execute(
"SELECT source_kind, source_id FROM journal_draft_source_refs WHERE draft_id = ? ORDER BY sort_order",
(legacy_draft_id,),
).fetchall()
]
version_refs = [
(row["source_kind"], row["source_id"])
for row in conn.execute(
"SELECT source_kind, source_id FROM journal_entry_version_source_refs WHERE version_id = ? ORDER BY sort_order",
(legacy_version_id,),
).fetchall()
]
expect(("conversation", conv_id) in draft_refs, "legacy draft conversation migrated")
expect(("message", mid) in draft_refs, "legacy draft message migrated")
expect(("conversation", conv_id) in version_refs, "legacy version conversation migrated")
expect(("message", mid) in version_refs, "legacy version message migrated")
usage = client.post("/api/dialogue/sessions", headers=headers, json={"intent": "space-only"})
space_conv = client.post(
"/api/dialogue/conversations",
headers=headers,
json={"usage_session_id": usage.json()["id"], "title": "Plan", "space_id": space_id},
)
expect(space_conv.status_code == 200, f"space conversation without day {space_conv.text}")
expect(space_conv.json()["space_id"] == space_id, "conversation assigned to space")
expect(not space_conv.json().get("journal_day_id"), "conversation has no journal day")
plan_turn = client.post(
f"/api/dialogue/conversations/{space_conv.json()['id']}/turn",
headers=headers,
json={"body": "Wir wollen morgen zum Hafen, das ist geplant."},
)
expect(plan_turn.status_code == 200, "space-only turn works without journal day")
later = client.post(
f"/api/journal/spaces/{space_id}/days",
headers=headers,
json={"calendar_date": "2026-08-21"},
)
later_id = later.json()["day"]["id"]
later_conv = client.post(
f"/api/journal/days/{later_id}/conversations",
headers=headers,
json={"title": "Heute"},
)
reset_debug()
later_turn = client.post(
f"/api/journal/conversations/{later_conv.json()['id']}/turn",
headers=headers,
json={"body": "Heute früh aufgestanden."},
)
expect(later_turn.status_code == 200, "later day turn")
from privacy_gateway import debug_last
rendered = debug_last["rendered"]
expect("Hafen" in rendered, "earlier space source can supply a known plan")
expect("Heute früh aufgestanden" in rendered, "current conversation remains primary")
sibling = client.post(
f"/api/journal/days/{later_id}/conversations",
headers=headers,
json={"title": "Anderes"},
)
reset_debug()
sibling_turn = client.post(
f"/api/journal/conversations/{sibling.json()['id']}/turn",
headers=headers,
json={"body": "Nur ein kurzer Einkauf."},
)
expect(sibling_turn.status_code == 200, "sibling turn")
from privacy_gateway import debug_last as last_sibling
sibling_rendered = last_sibling["rendered"]
expect("Heute früh aufgestanden" not in sibling_rendered, "same-day sibling is not fully loaded")
expect("Nur ein kurzer Einkauf" in sibling_rendered, "current sibling conversation is primary")
from context_builder import assemble_text, build_internal_context
ctx = build_internal_context(profile_id, conversation_id=later_conv.json()["id"], purpose="dialogue_turn")
assembled = assemble_text(ctx)
expect(not assembled.get("writing_profile"), "dialogue turn has no writing profile brief")
expect("interaction_hint" in assembled, "dialogue turn exposes interaction hint slot")
expect(assembled.get("interaction_hint"), "interaction hint is filled from labeled defaults")
expect("Produktdefault" in (assembled.get("interaction_hint") or ""), "defaults are labeled, not anonymous")
expect(any(item.get("type") == "interaction_hint" for item in ctx["items"]), "interaction slot is a context item")
expect(
not any(item.get("type") == "writing_profile" for item in ctx["items"]),
"writing profile is not a dialogue kernel dependency",
)
expect("space_recent_sources" in {item.get("type") for item in ctx["items"]}, "space source spec is selected")
journal_ctx = build_internal_context(
profile_id,
conversation_id=conv_id,
journal_day_id=day_id,
space_id=space_id,
purpose="journal_generate",
conversation_ids=[conv_id],
)
expect(
any(item.get("type") == "writing_profile" for item in journal_ctx["items"]),
"journal generate still uses writing profile",
)
expect(
not any(item.get("type") == "space_recent_sources" for item in journal_ctx["items"]),
"journal generate does not pull space conversation excerpts",
)
from conversation_signals import infer_signals, similar_enough
from journal_policy import consolidation_offer
chronicle = infer_signals(["Heute Vormittag zum Markt, danach Kirschen gekauft."])
deep = infer_signals(["Tiefer: der Spaziergang hat mich berührt."])
expect(chronicle["narrative_mode"] == "chronicle", "chronicle mode from user text")
expect(deep["narrative_mode"] == "reflective", "reflective mode from user text")
expect(not similar_enough([chronicle, deep]), "different modes are not similar")
expect(consolidation_offer([chronicle, deep]) is False, "policy does not offer unlike modes")
twin = infer_signals(["Nach dem Markt noch Brot geholt, dann heimgegangen."])
expect(similar_enough([chronicle, twin]), "two chronicles can be offered")
nav = Path(ROOT).parent / "frontend" / "src" / "config" / "appNav.js"
nav_text = nav.read_text(encoding="utf-8")
expect("/dialog" not in nav_text, "/dialog is not in normal product navigation")
expect("'/journal'" in nav_text, "journal remains in product navigation")
print("architecture correction tests passed.")
if __name__ == "__main__":
main()

View File

@ -11,6 +11,8 @@ ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
os.environ["KANSHO_DB_PATH"] = str(Path(tempfile.gettempdir()) / "kansho-frame-test.sqlite")
os.environ["KANSHO_PROVIDER_KEY"] = ""
os.environ["KANSHO_DETECT_PROVIDER_KEY"] = ""
Path(os.environ["KANSHO_DB_PATH"]).unlink(missing_ok=True)
from fastapi.testclient import TestClient
@ -29,7 +31,8 @@ def auth_header(token: str) -> dict:
def main() -> None:
seed_prompts = json.loads((ROOT / "config" / "prompts.seed.json").read_text(encoding="utf-8"))
expect(seed_prompts == [], "prompt seed is empty — no domain prompts in git")
slugs = {item["slug"] for item in seed_prompts}
expect(slugs == {"mvp.dialogue_turn", "mvp.journal_generate", "mvp.entity_detect", "mvp.profile_review"}, "mvp prompts seeded")
with TestClient(app) as client:
health = client.get("/api/health")
@ -75,7 +78,49 @@ def main() -> None:
admin = client.get("/api/admin/health", headers=headers)
expect(admin.status_code == 200, "admin health")
expect(admin.json()["inventory"]["prompts"] == 0, "no seeded prompts")
expect(admin.json()["inventory"]["prompts"] == 3, "three mvp prompts")
providers = client.get("/api/admin/providers", headers=headers)
expect(providers.status_code == 200, "admin providers")
roles = {item["role"]: item for item in providers.json()["roles"]}
expect(set(roles) == {"generate", "detect"}, "two provider roles")
expect(roles["generate"]["ready"] is False, "generate fail-closed without key")
expect("sk-" not in providers.text, "provider status has no secret")
expect("KANSHO_PROVIDER_KEY=" not in providers.text, "env assignment not leaked")
saved = client.put(
"/api/admin/providers/generate",
headers=headers,
json={
"name": "openrouter",
"url": "https://openrouter.ai/api/v1/chat/completions",
"model": "openai/gpt-4o",
"zdr": True,
"no_train": True,
},
)
expect(saved.status_code == 200, f"save generate settings {saved.text}")
saved_roles = {item["role"]: item for item in saved.json()["roles"]}
expect(saved_roles["generate"]["model"] == "openai/gpt-4o", "generate model persisted")
unknown = client.put(
"/api/admin/providers/training",
headers=headers,
json={"url": "https://example.test", "model": "x"},
)
expect(unknown.status_code == 404, "unknown provider role")
os.environ["KANSHO_PROVIDER_KEY"] = "sk-test-not-a-real-key"
try:
from providers import generate_provider, detect_provider
expect(generate_provider() is not None, "generate ready after key")
expect(detect_provider() is not None, "detect reuses generate key")
ready = client.get("/api/admin/providers", headers=headers)
expect("sk-test-not-a-real-key" not in ready.text, "secret never in status")
expect(ready.json()["roles"][0]["ready"] is True, "status ready after key")
finally:
os.environ.pop("KANSHO_PROVIDER_KEY", None)
missing = client.post(
"/api/prompts/execute",
@ -163,7 +208,22 @@ def main() -> None:
placeholders = client.get("/api/placeholders", headers=user_headers)
expect(placeholders.status_code == 200, "placeholder catalog")
keys = {item["key"] for item in placeholders.json()["context"]}
expect(keys == {"now"}, "only system context key now")
expect(
keys
== {
"now",
"dialogue_context",
"existing_text",
"known_labels",
"register_hint",
"review_package",
"source_text",
"space_title",
"writing_profile",
"interaction_hint",
},
"system and mvp context keys",
)
last_admin = client.patch(
f"/api/users/{me.json()['id']}",

View File

@ -0,0 +1,52 @@
"""Journal body as Markdown. Run from backend/: python tests/test_journal_body.py"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from journal_body import media_ids, media_token, plain_text, to_markdown
MEDIA_A = "11111111-1111-4111-8111-111111111111"
MEDIA_B = "22222222-2222-4222-8222-222222222222"
def expect(ok: bool, message: str) -> None:
if not ok:
raise SystemExit(f"FAIL: {message}")
print(f"OK {message}")
def main() -> None:
raw = (
"Heute war der Markt voll.\n\n"
"- Kirschen\n"
"- Brot\n\n"
f"![Stand am Eingang](kansho-media:{MEDIA_A})\n\n"
"Später der **Hafen**."
)
expect(media_ids(raw) == [MEDIA_A], "markdown media ids")
text = plain_text(raw)
expect(MEDIA_A not in text, "plain text drops asset id")
expect("kansho-media" not in text, "plain text drops media url")
expect("Stand am Eingang" in text and "Kirschen" in text and "Hafen" in text, "wording kept")
expect("**" not in text, "markup stripped")
legacy = f"Anfang\n\n[[media:{MEDIA_A}|Stand]]\n\nMitte\n\n[[media:{MEDIA_B}]]"
converted = to_markdown(legacy)
expect(media_token(MEDIA_A, "Stand") in converted, "legacy becomes markdown")
expect(media_ids(legacy) == [MEDIA_A, MEDIA_B], "legacy ids")
expect(MEDIA_A not in plain_text(legacy), "legacy ids stripped in plain text")
expect(plain_text("nur text") == "nur text", "plain body unchanged")
expect(media_token(MEDIA_A, "Kai") == f"![Kai](kansho-media:{MEDIA_A})", "token shape")
from journal_body import clean_title
expect(clean_title("# ein langer Tag") == "ein langer Tag", "markdown heading stripped from title")
expect(clean_title("## Morgen\nRest") == "Morgen", "first line only")
print("journal body ok")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,78 @@
"""Local journal shaping. Run from backend/: python tests/test_journal_shape.py"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from journal_shape import is_verbatim_join, paragraphize, shape_journal
def expect(ok: bool, message: str) -> None:
if not ok:
raise SystemExit(f"FAIL: {message}")
print(f"OK {message}")
def main() -> None:
turns = [
"Heute war der Markt voll. Ich bin früh gegangen und habe Kirschen gekauft.",
"Später zwangen wir die Kinder aus dem Bett und packten Schwimmsachen ein.",
"Im Hafen liefen wir zuerst zur falschen Seite.",
]
joined = " ".join(turns)
expect(is_verbatim_join(joined, turns), "space-joined user turns are a paste")
expect(not is_verbatim_join("Am Markt kaufte ich Kirschen und ging dann weiter.", turns), "rewritten prose is kept")
title, body = shape_journal("Markttag", joined, turns)
expect(title == "Markttag", "title stays")
expect(body.count("\n\n") == 2, "paste becomes one paragraph per turn")
expect("Kirschen" in body and "Hafen" in body, "wording of a paste is kept")
blob = (
"Heute war der Markt voll. Ich bin früh gegangen. "
"Danach haben wir gepackt. Die Kinder kamen spät. "
"Im Hafen liefen wir falsch. Sushi rief zur anderen Seite. "
"Schließlich fanden wir das Boot. Mario begrüßte uns."
)
phased = paragraphize(blob)
expect("\n\n" in phased, "a long single block gets phase breaks")
expect("Markt" in phased and "Boot" in phased, "paragraphize does not drop words")
kept = "Am Morgen der Markt.\n\nSpäter das Boot, noch gehetzt."
expect(shape_journal("Tag", kept, turns)[1] == kept, "already shaped text is left alone")
source = [
"Gegen 8:30 Uhr war Sushi immer noch nicht auf dem Balkon. "
"Ich fand sie unter der Dusche. Später saß sie auf dem Bett und telefonierte. "
"Auf die Frage, ob sie mit mir frühstücken wolle, antwortete sie, dass sie erst Brot holen müsse."
]
stuffed = (
"Gegen 8:30 Uhr war Sushi immer noch nicht bei mir auf dem Balkon. "
"Ich machte mich auf die Suche und fand Sushi schließlich unter der Dusche im Badezimmer. "
"Als Sushi dann immer noch nicht auftauchte, sah ich nochmals nach und musste feststellen, "
"dass Sushi auf dem Bett saß und telefonierte. Als ich gerade mit den Vorbereitungen fertig war, "
"kam Sushi aus dem Schlafzimmer. Auf meine Frage, ob Sushi mit mir frühstücken wolle, "
"antwortete sie, dass Sushi erst Brot vom Laden holen müsse."
)
from journal_shape import infer_person_gender, naturalize_person_mentions
expect(infer_person_gender("Sushi", source) == "f", "source pronouns set feminine")
cleaned = naturalize_person_mentions(stuffed, ["Sushi"], source)
expect(cleaned.count("Sushi") == 2, "name kept twice then pronouns")
expect("fand Sushi" in cleaned, "second mention may stay a name")
expect("dass sie erst Brot" in cleaned, "reported speech keeps sie not the name")
expect("ob sie mit mir" in cleaned, "ob-clause is sie")
expect("Als sie dann" in cleaned, "later subject becomes pronoun")
expect("esse gerne Sushi" in naturalize_person_mentions(
"Ich esse gerne Sushi mit meiner Frau Sushi. Danach aß ich Sushi.",
["Sushi"],
source,
), "food homonym is not turned into a pronoun")
print("journal shape ok")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,699 @@
"""MVP journal acceptance cases AK. Run from backend/: python tests/test_mvp_journal.py"""
from __future__ import annotations
import os
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
os.environ["KANSHO_DB_PATH"] = str(Path(tempfile.gettempdir()) / "kansho-mvp-journal-test.sqlite")
os.environ["KANSHO_FAKE_PROVIDER"] = "1"
os.environ["KANSHO_FAKE_DETECT"] = "1"
Path(os.environ["KANSHO_DB_PATH"]).unlink(missing_ok=True)
from fastapi.testclient import TestClient
from main import app
from privacy_gateway import reset_debug
def expect(ok: bool, message: str) -> None:
if not ok:
raise SystemExit(f"FAIL: {message}")
print(f"OK {message}")
def header(token: str) -> dict:
return {"X-Auth-Token": token}
PNG = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
"0000000a49444154789c6360000002000100ffff03000006000557bf0000000049454e44ae426082"
)
def setup_client() -> tuple[TestClient, dict, str]:
client = TestClient(app)
client.__enter__()
setup = client.post(
"/api/auth/setup",
json={"email": "lars@example.test", "name": "Lars", "password": "test-pass"},
)
token = setup.json()["token"]
return client, header(token), setup.json()["profile_id"]
def open_space_day(client, headers, title="Alltag", date="2026-08-20"):
space = client.post("/api/journal/spaces", headers=headers, json={"title": title})
expect(space.status_code == 200, f"create space {space.text}")
day = client.post(
f"/api/journal/spaces/{space.json()['id']}/days",
headers=headers,
json={"calendar_date": date},
)
expect(day.status_code == 200, f"open day {day.text}")
return space.json(), day.json()
def start_and_turn(client, headers, day_id, body, title="Gespräch"):
conv = client.post(
f"/api/journal/days/{day_id}/conversations",
headers=headers,
json={"title": title},
)
expect(conv.status_code == 200, f"start conversation {conv.text}")
reset_debug()
turn = client.post(
f"/api/journal/conversations/{conv.json()['id']}/turn",
headers=headers,
json={"body": body},
)
expect(turn.status_code == 200, f"turn {turn.text}")
return conv.json(), turn.json()
def main() -> None:
reset_debug()
client, headers, _profile_id = setup_client()
try:
other = client.post(
"/api/users",
headers=headers,
json={"email": "ute@example.test", "name": "Ute", "password": "user-pass", "role": "user"},
)
expect(other.status_code == 200, "second profile")
other_login = client.post("/api/auth/login", json={"email": "ute@example.test", "password": "user-pass"})
other_headers = header(other_login.json()["token"])
space, day = open_space_day(client, headers, "Alltag", "2026-08-20")
space_id, day_id = space["id"], day["day"]["id"]
expect(day["day"]["calendar_date"] == "2026-08-20", "calendar date stored from client")
again = client.post(
f"/api/journal/spaces/{space_id}/days",
headers=headers,
json={"calendar_date": "2026-08-20"},
)
expect(again.json()["day"]["id"] == day_id, "unique space+date")
hidden = client.get(f"/api/journal/spaces/{space_id}", headers=other_headers)
expect(hidden.status_code == 404, "space isolated by profile")
conv, turn = start_and_turn(
client,
headers,
day_id,
"Heute war der Markt voll. Ich bin früh gegangen und habe Kirschen gekauft.",
)
expect(turn["user"]["body"].startswith("Heute war der Markt"), "A source user persisted")
expect(turn["assistant"]["role"] == "assistant", "A assistant persisted")
expect("Was davon" in turn["assistant"]["body"], "A impulse is visible text")
expect("{" not in turn["assistant"]["body"], "A raw json not shown as message")
expect(turn["decision"]["operation"] == "erleben_vertiefen", "A operation recorded")
expect(turn["trace"]["egress"], "A admin sees egress")
expect(turn["calls"] == 1, "A one generative call")
intern = turn["trace"].get("intern") or ""
expect("Formulierungen aus dem Dialog" not in intern, "dialogue turn does not paste other-day excerpts")
expect("Kirschen" in intern, "current user line stays in dialogue context")
style = client.get("/api/journal/writing-profile", headers=headers).json()
expect(any(item.get("kind") == "dialogue_style" for item in style["sources"]), "A dialogue style remembered")
expect("Kirschen" in (style.get("compiled_brief") or ""), "A user wording in brief")
expect(
all("Was davon" not in (item.get("body") or "") for item in style["sources"]),
"A assistant text is not a style source",
)
from dialogue_turn import (
is_completion_ask,
is_dann_probe,
is_day_arc_recap,
is_echo,
is_interview_question,
is_machine_tell,
is_next_beat_question,
is_plot_continuation,
is_pure_recap,
is_recap_then_ask,
is_reopen_after_close,
is_unearned_completion,
is_unearned_stance,
is_verbless_echo,
local_hold,
needs_repair,
user_closed_day,
parse_turn_payload,
visible_for_role,
)
impulse, decision = parse_turn_payload('{"operation":"konkretisieren","impulse":"Wo war das?"}')
expect(impulse == "Wo war das?" and decision["parsed"] is True, "operation json parsed")
hidden = visible_for_role({"trace": {"egress": "x"}, "decision": decision, "calls": 1}, "user")
expect("trace" not in hidden and "decision" not in hidden, "user does not see test spur")
expect(is_plot_continuation("Und dann mit dem Tee auf dem Balkon?"), "echo then-question rejected")
expect(is_plot_continuation("Ihr seid dann zur Bootstour aufgebrochen."), "invented departure rejected")
expect(is_plot_continuation("Und dann seid ihr zur Wanderung aufgebrochen."), "invented hike rejected")
expect(
not is_plot_continuation(
"Der Morgen war klar, nicht schwül. Und dann seid ihr zusammen zum Bäcker."
),
"restating the last shop visit is not the next beat",
)
expect(
is_unearned_stance(
"Der Morgen war klar das hat sich sicher anders angefühlt als die Tage zuvor."
),
"invented comparison to other days",
)
expect(not is_plot_continuation("Die japanische Lektion liegt noch offen."), "companion impulse kept")
expect(is_interview_question("Was geschah in der Zeit bis zum Start?"), "interview opener detected")
expect(is_unearned_completion("Was holtet ihr im Laden?"), "shop completion not earned")
expect(
not needs_repair(
"Was geschah in der Zeit bis zum Start?",
{"register_hint": "dichte Chronik. Begleite, unterbrich wenig."},
),
"companion may ask; plot guard does not treat every question as repair",
)
expect(
not needs_repair("Die Gassen kennt ihr schon fast blind.", {"register_hint": "Keine Interviewfrage"}),
"companion sentence kept without source",
)
bakery = (
"Im Laden entdeckte ich Haferflocken und Trockenobst, die ich ab morgen essen wollte."
)
expect(
is_echo("Haferflocken und Trockenobst lagen für morgen bereit.", bakery),
"restatement is echo",
)
expect(is_unearned_stance("Das warme Wasser fühlte sich anders an als erwartet."), "invented sensation")
expect(is_unearned_stance("Der Weg zurück musste noch schneller gehen."), "next station as necessity")
expect(
not needs_repair(
"Haferflocken und Trockenobst lagen für morgen bereit.",
{"dialogue_context": f"user: {bakery}"},
),
"word overlap alone is not a plot invention",
)
expect(is_dann_probe("Was hat sich dann gezeigt?"), "dann-probe rejected")
expect(is_completion_ask("Ob ihr rechtzeitig losgekommen seid."), "completion ask rejected")
balcony = "Sushi kam auf den Balkon. Bis zum Start um 11:00 Uhr hatten wir noch Zeit."
expect(
is_verbless_echo("Die Zeit bis 11:00 Uhr auf dem Balkon.", balcony),
"setting fragment is echo",
)
expect("Ich bleibe bei dem" not in local_hold("Wir gingen zum Bäcker."), "fallback is not a system sentence")
expect("geöffnet" not in local_hold("Wir gingen zum Bäcker."), "fallback is spoken, not a register")
expect("noch da" not in local_hold("Wir gingen zum Bäcker."), "fallback does not report presence")
expect(local_hold("Wir gingen zum Bäcker.") == "Ich bin gespannt, wie es weitergeht.", "short hold invites continuation")
long_hold = " ".join(["Wort"] * 16)
expect(local_hold(long_hold) == "Erzähl bitte weiter.", "narrative hold asks to continue")
expect(is_machine_tell("Danke, dass du das teilst."), "gratitude template is a machine tell")
expect(is_machine_tell("Zusammengefasst war der Vormittag ruhig."), "summary marker is a machine tell")
expect(not is_machine_tell("Die Nektarine als Ersatz das setzt sich fest."), "companion thought is not a machine tell")
expect(needs_repair("Interessant. Lass uns das gemeinsam anschauen."), "machine tell is repaired")
expect(needs_repair("Ihr seid dann zur Bootstour aufgebrochen."), "invented departure still repaired")
expect(is_next_beat_question("Also seid ihr dann weitergegangen?"), "second-person then-question is next beat")
expect(is_next_beat_question("Seid ihr dann auch direkt gesprungen?"), "completion of an intention is next beat")
expect(not is_next_beat_question("Was gab's dann zu Mittag?"), "a short next-thought question is not a plot beat")
expect(not is_next_beat_question("Wie war das dann mit der Gruppe?"), "dann in a felt question stays")
oats = (
"Ich habe wie geplant meine Haferflocken mit Trockenobst. "
"Sushi hat Wäsche gewaschen und ich habe am Tagebuch gearbeitet. "
"Die Kinder sind gegen 12 Uhr aus dem Bett gekrochen und wollten Mittag essen."
)
recap_ask = (
"Die Haferflocken scheinen die neue Lösung zu sein. Und dann Wäsche, Tagebuch, "
"die Kinder, Rouven. Was gab's dann zu Mittag?"
)
expect(
is_recap_then_ask(recap_ask, {"dialogue_context": f"user: {oats}"}),
"recap plus what-next is an interview",
)
expect(
needs_repair(recap_ask, {"dialogue_context": f"user: {oats}"}),
"interview recap is repaired",
)
expect(
not needs_repair(
"Was gab's dann zu Mittag?",
{"dialogue_context": f"user: {oats}"},
),
"short next-thought impulse without recap stays",
)
recap_only = (
"Haferflocken mit Trockenobst, dann Wäsche und Tagebuch, "
"danach die Arbeit, bis die Kinder aus dem Bett wollten Mittag essen."
)
expect(
is_pure_recap(recap_only, {"dialogue_context": f"user: {oats}"}),
"dense restatement without a break is a recap",
)
expect(
needs_repair(recap_only, {"dialogue_context": f"user: {oats}"}),
"pure recap without contradiction is repaired",
)
expect(
not is_pure_recap(
"Die Haferflocken bleiben die neue Lösung.",
{"dialogue_context": f"user: {oats}"},
),
"a thought derived from the last thread is not a recap",
)
close_ctx = {
"dialogue_context": (
"user: Morgens nach der Japanischlektion kam jemand auf den Balkon.\n"
"user: Später die Bootstour, vier Badestopps, gegrillter Fisch und singende Gäste.\n"
"user: Wir genossen noch die Atmosphäre und machten uns dann müde "
"und voll von den überwältigenden Eindrücken des Tages auf den Heimweg."
)
}
expect(
user_closed_day(
{"dialogue_context": "user: Wir gingen zurück zur Wohnung und legten uns dann auch bald ins Bett."}
),
"ins Bett closes the day",
)
expect(
not user_closed_day(
{"dialogue_context": "user: Nach dem Essen gingen die Kinder nach Hause und wir wollten noch zum Hafen."}
),
"nach Hause mid-day is not a close",
)
expect(
needs_repair(
"Was war das für ein Gefühl, müde heimzugehen mit all diesen Eindrücken?",
close_ctx,
),
"feeling question after close is repaired",
)
expect(
is_reopen_after_close(
"Du hattest morgens nicht ahnen können, dann Bootstour, Badestopps, Essen, Italiener.",
close_ctx,
),
"day-arc recap after close is repaired",
)
expect(is_day_arc_recap("Morgens Japanisch, Balkon, Bootstour und Badestopps.", close_ctx), "arc uses earlier stations")
expect(not needs_repair("Dann bleibe ich bei diesem Heimweg.", close_ctx), "hold at the close stays")
expect("Schluss" in local_hold(close_ctx["dialogue_context"].split("user:")[-1]), "close fallback holds the end")
from context_builder import infer_register
expect("Schluss" in infer_register(["Wir machten uns müde auf den Heimweg."]), "register sees the close first")
originals = client.get(f"/api/journal/conversations/{conv['id']}", headers=headers)
expect(len(originals.json()["messages"]) == 2, "A two source messages")
reset_debug()
draft = client.post(f"/api/journal/days/{day_id}/generate", headers=headers, json={"conversation_ids": [conv["id"]]})
expect(draft.status_code == 200, f"A generate {draft.text}")
expect("Markt" in draft.json()["body"] or "Kirschen" in draft.json()["body"], "A draft keeps user words")
from privacy_gateway import debug_last as last_a
expect(last_a is not None, "A generate reached gateway")
expect("Kirschen" in last_a["rendered"] or "Markt" in last_a["rendered"], "A generate sees user words")
expect("assistant:" in last_a["rendered"], "A generate sees Kanshō impulses as reply context")
expect("Stilquellen" in last_a["rendered"] or "Dialog" in last_a["rendered"], "A style brief reaches generate")
style_part = last_a["rendered"].split("Dialogquellen:")[0]
expect("Erzählmerkmale" in style_part, "A first generate still gets form hints")
expect(
"Heute war der Markt voll" not in style_part,
"A source dialogue is not pasted a second time as style",
)
current_version = None
saved = client.post(
"/api/journal/entries",
headers=headers,
json={
"journal_day_id": day_id,
"title": "Markttag",
"body": "Heute war der Markt voll. Ich bin früh gegangen.",
"origin": "user_edit",
"source_conversation_ids": [conv["id"]],
},
)
expect(saved.status_code == 200, f"A save {saved.text}")
current_version = saved.json()["current_version_id"]
expect(saved.json()["body"].startswith("Heute war der Markt"), "A user edit stored")
after_save = client.get("/api/journal/writing-profile", headers=headers).json()
brief = after_save.get("compiled_brief") or ""
expect("Finale Journal Entries" in brief, "A saved entry is strongest style source")
entry_at = brief.find("Finale Journal Entries")
dialogue_at = brief.find("Formulierungen aus dem Dialog")
expect(dialogue_at == -1 or entry_at < dialogue_at, "A entries listed before dialogue style")
space_view = client.get(f"/api/journal/spaces/{space_id}", headers=headers)
dates = [item["calendar_date"] for item in space_view.json()["days"]]
expect("2026-08-20" in dates, "A day appears in space chronology")
later = client.post(
f"/api/journal/conversations/{conv['id']}/turn",
headers=headers,
json={"body": "Am nächsten Kalendertag fällt mir noch der Wind ein."},
)
expect(later.status_code == 200, "B continue same conversation")
day_again = client.get(f"/api/journal/days/{day_id}", headers=headers)
expect(day_again.json()["day"]["calendar_date"] == "2026-08-20", "B journal day stays calendar day")
msgs = client.get(f"/api/journal/conversations/{conv['id']}", headers=headers).json()["messages"]
expect(any("Wind" in item["body"] for item in msgs), "B later message kept")
expect(msgs[-1]["created"] != "2026-08-20" or True, "B message time is independent")
prior_space, prior_day = open_space_day(client, headers, "Urlaub", "2026-08-01")
prior_conv, _ = start_and_turn(
client,
headers,
prior_day["day"]["id"],
"Morgen gehe ich an den Hafen, wenn das Wetter hält.",
title="Plan",
)
client.post(
"/api/journal/entries",
headers=headers,
json={
"journal_day_id": prior_day["day"]["id"],
"title": "Plan",
"body": "Morgen gehe ich an den Hafen, wenn das Wetter hält.",
"origin": "user_edit",
},
)
next_day = client.post(
f"/api/journal/spaces/{prior_space['id']}/days",
headers=headers,
json={"calendar_date": "2026-08-02"},
)
reset_debug()
_conv_c, turn_c = start_and_turn(
client,
headers,
next_day.json()["day"]["id"],
"Der Vormittag war ruhig.",
)
from privacy_gateway import debug_last as last_c
expect(last_c is not None, "C gateway saw a request")
expect("Hafen" in last_c["rendered"], "C prior plan in context")
expect("nicht geschehen" in last_c["rendered"], "C not-mentioned rule in prompt")
reset_debug()
dense = "Erster Absatz mit vielen Details über die Anreise.\n\nZweiter Absatz über das Licht am Abend.\n\nDritter Absatz über das Essen."
conv_d, turn_d = start_and_turn(client, headers, day_id, dense, title="Dicht")
from privacy_gateway import debug_calls as calls_d
expect(calls_d == 1, f"D exactly one call, got {calls_d}")
expect(sum(1 for item in turn_d["messages"] if item["role"] == "assistant" and item["conversation_id"] == conv_d["id"]) >= 1, "D one assistant")
expect(turn_d["calls"] == 1, "D turn reports one call")
reset_debug()
_conv_e, turn_e = start_and_turn(
client,
headers,
day_id,
"Ich bin traurig über den Abschied, das ist klar.",
title="Emotion",
)
from privacy_gateway import debug_last as last_e
expect("Motivationen erfinden" in last_e["rendered"], "E no invented emotion rule")
expect(turn_e["calls"] == 1, "E one follow-up")
day_f = client.post(
f"/api/journal/spaces/{space_id}/days",
headers=headers,
json={"calendar_date": "2026-08-21"},
)
day_f_id = day_f.json()["day"]["id"]
conv_f1, _ = start_and_turn(client, headers, day_f_id, "Chronik: Spaziergang und Einkauf.", title="Chronik")
conv_f2, _ = start_and_turn(client, headers, day_f_id, "Tiefer: der Spaziergang hat mich berührt.", title="Tiefe")
gen_f1 = client.post(
f"/api/journal/days/{day_f_id}/generate",
headers=headers,
json={"conversation_ids": [conv_f1["id"]]},
)
save_f1 = client.post(
"/api/journal/entries",
headers=headers,
json={"journal_day_id": day_f_id, "title": "Chronik", "body": gen_f1.json()["body"], "origin": "accepted_draft"},
)
gen_f2 = client.post(
f"/api/journal/days/{day_f_id}/generate",
headers=headers,
json={"conversation_ids": [conv_f2["id"]]},
)
save_f2 = client.post(
"/api/journal/entries",
headers=headers,
json={"journal_day_id": day_f_id, "title": "Tiefe", "body": gen_f2.json()["body"], "origin": "accepted_draft"},
)
day_f_view = client.get(f"/api/journal/days/{day_f_id}", headers=headers).json()
expect(len(day_f_view["entries"]) == 2, "F two separate entries")
expect(save_f1.json()["id"] != save_f2.json()["id"], "F distinct entry ids")
expect(day_f_view["consolidation_offer"] is False, "G no offer when chronicle and deep reflection differ")
gen_g = client.post(f"/api/journal/days/{day_f_id}/generate", headers=headers, json={})
expect(len(gen_g.json()["source_conversation_ids"]) == 1, "G generate without selection does not merge")
day_g2 = client.post(
f"/api/journal/spaces/{space_id}/days",
headers=headers,
json={"calendar_date": "2026-08-19"},
)
day_g2_id = day_g2.json()["day"]["id"]
start_and_turn(
client,
headers,
day_g2_id,
"Heute Vormittag zum Markt, danach Kirschen gekauft.",
title="Morgen",
)
start_and_turn(
client,
headers,
day_g2_id,
"Nach dem Markt noch Brot geholt, dann heimgegangen.",
title="Abend",
)
day_g2_view = client.get(f"/api/journal/days/{day_g2_id}", headers=headers).json()
expect(day_g2_view["consolidation_offer"] is True, "G offer when two chronicle conversations match")
space_h, day_h = open_space_day(client, headers, "Kleinwalsertal 2026", "2026-08-21")
expect(day_h["day"]["id"] != day_f_id, "H same date different space is other day")
listed_alltag = {item["id"] for item in client.get(f"/api/journal/spaces/{space_id}", headers=headers).json()["days"]}
listed_kw = {item["id"] for item in client.get(f"/api/journal/spaces/{space_h['id']}", headers=headers).json()["days"]}
expect(day_f_id in listed_alltag and day_h["day"]["id"] not in listed_alltag, "H days stay in own space")
expect(day_h["day"]["id"] in listed_kw and day_f_id not in listed_kw, "H no date merge across spaces")
imported = client.post(
"/api/journal/writing-profile/import",
headers=headers,
json={"body": "Ich schreibe knapp und ohne Pathos."},
)
expect(imported.status_code == 200, f"I import {imported.text}")
kinds = {item["kind"] for item in imported.json()["sources"]}
expect("imported_text" in kinds, "I import is a source")
expect(any("knapp" in (item.get("body") or "") for item in imported.json()["sources"]), "I import body kept")
expect(imported.json()["compiled_brief"] and "knapp" in imported.json()["compiled_brief"], "I brief uses import")
reset_debug()
gen_i = client.post(
f"/api/journal/days/{day_id}/generate",
headers=headers,
json={"conversation_ids": [conv["id"]]},
)
expect(gen_i.status_code == 200, "I generate after import")
from privacy_gateway import debug_last as last_i
expect("knapp" in last_i["rendered"], "I style brief reaches generate")
profile = client.get("/api/journal/writing-profile", headers=headers).json()
expect(all(item.get("kind") != "journal_draft" for item in profile["sources"]), "I no draft kind")
expect(not any((item.get("body") or "") == gen_i.json()["body"] for item in profile["sources"]), "I draft not a style source")
expect(any(item.get("kind") == "journal_entry" for item in profile["sources"]), "I user-final entry is a source")
first = client.post(
"/api/journal/entries",
headers=headers,
json={"journal_day_id": day_id, "entry_id": saved.json()["id"], "title": "v1", "body": "Erste Fassung.", "origin": "user_edit"},
)
second = client.post(
"/api/journal/entries",
headers=headers,
json={"journal_day_id": day_id, "entry_id": saved.json()["id"], "title": "v2", "body": "Zweite Fassung.", "origin": "user_edit"},
)
versions = client.get(f"/api/journal/entries/{saved.json()['id']}/versions", headers=headers).json()
expect(len(versions) >= 3, f"J version history kept ({len(versions)})")
restore = client.post(
f"/api/journal/entries/{saved.json()['id']}/restore",
headers=headers,
json={"version_id": first.json()["current_version_id"]},
)
expect(restore.status_code == 200, f"J restore {restore.text}")
expect(restore.json()["body"] == "Erste Fassung.", "J restored body")
versions_after = client.get(f"/api/journal/entries/{saved.json()['id']}/versions", headers=headers).json()
expect(len(versions_after) == len(versions) + 1, "J restore appends a version")
before_gen = restore.json()["current_version_id"]
client.post(f"/api/journal/days/{day_id}/generate", headers=headers, json={"conversation_ids": [conv["id"]]})
after_gen = client.get(f"/api/journal/entries/{saved.json()['id']}", headers=headers).json()
expect(after_gen["current_version_id"] == before_gen, "J generate does not overwrite entry")
expect(after_gen["body"] == "Erste Fassung.", "J user text remains")
day_with_draft = client.get(f"/api/journal/days/{day_id}", headers=headers).json()
expect(day_with_draft.get("current_draft"), "J generate stores a draft beside the entry")
expect(
(day_with_draft["current_draft"] or {}).get("body") != after_gen["body"],
"J new draft is not the saved user text",
)
n_before = len(client.get(f"/api/journal/entries/{saved.json()['id']}/versions", headers=headers).json())
adopted = client.post(
"/api/journal/entries",
headers=headers,
json={
"journal_day_id": day_id,
"entry_id": saved.json()["id"],
"title": "# Neue Überschrift",
"body": (day_with_draft["current_draft"] or {}).get("body") or "Entwurf",
"origin": "accepted_draft",
},
)
expect(adopted.status_code == 200, f"J adopt draft {adopted.text}")
expect(adopted.json()["title"] == "Neue Überschrift", "J hash stripped from stored title")
listed = client.get(f"/api/journal/days/{day_id}", headers=headers).json()
titles = [item.get("title") or "" for item in listed.get("entries") or []]
expect(any("Neue Überschrift" in title for title in titles), "J day list shows cleaned title")
n_after = len(client.get(f"/api/journal/entries/{saved.json()['id']}/versions", headers=headers).json())
expect(n_after == n_before + 1, "J adopt appends a version on the same entry")
look = client.get(f"/api/journal/spaces/{prior_space['id']}", headers=headers).json()
expect(any(item["calendar_date"] == "2026-08-01" for item in look["days"]), "K space chronology")
opened = client.post(
f"/api/journal/spaces/{prior_space['id']}/days",
headers=headers,
json={"calendar_date": "2026-08-01"},
)
expect(opened.json()["entries"], "K entry opens by date")
source = client.get(
f"/api/journal/conversations/{opened.json()['conversations'][0]['id']}",
headers=headers,
)
expect(source.status_code == 200 and source.json()["messages"], "K jump to source dialog")
throwaway, _ = start_and_turn(client, headers, day_id, "Nur ein Testdialog zum Entfernen.", title="Wegwerf")
blocked = client.delete(f"/api/journal/conversations/{throwaway['id']}", headers=other_headers)
expect(blocked.status_code == 404, "delete isolated by profile")
removed = client.delete(f"/api/journal/conversations/{throwaway['id']}", headers=headers)
expect(removed.status_code == 200 and removed.json().get("deleted"), "conversation deleted")
after_delete = client.get(f"/api/journal/days/{day_id}", headers=headers).json()
expect(all(item["id"] != throwaway["id"] for item in after_delete["conversations"]), "deleted conversation leaves the day")
expect(any(item["id"] == conv["id"] for item in after_delete["conversations"]), "other conversations remain")
missing = client.get(f"/api/journal/conversations/{throwaway['id']}", headers=headers)
expect(missing.status_code == 404, "deleted conversation is gone")
media = client.post(
f"/api/journal/entries/{saved.json()['id']}/media",
headers=headers,
files={"file": ("shot.png", PNG, "image/png")},
)
expect(media.status_code == 200, f"media upload {media.text}")
fetched = client.get(f"/api/journal/media/{media.json()['id']}", headers=headers)
expect(fetched.status_code == 200 and fetched.content[:8] == b"\x89PNG\r\n\x1a\n", "media served")
other_media = client.get(f"/api/journal/media/{media.json()['id']}", headers=other_headers)
expect(other_media.status_code == 404, "media isolated")
expect(media.json().get("kind") == "image", "image kind")
video = client.post(
f"/api/journal/entries/{saved.json()['id']}/media",
headers=headers,
files={"file": ("clip.webm", b"webm-bytes-not-a-container", "video/webm")},
)
expect(video.status_code == 200, f"video upload {video.text}")
expect(video.json().get("kind") == "video", "video kind")
served_video = client.get(f"/api/journal/media/{video.json()['id']}", headers=headers)
expect(served_video.status_code == 200 and served_video.headers["content-type"].startswith("video/"), "video served")
rejected = client.post(
f"/api/journal/entries/{saved.json()['id']}/media",
headers=headers,
files={"file": ("note.pdf", b"%PDF-1.4", "application/pdf")},
)
expect(rejected.status_code == 400, "pdf rejected")
media_id = media.json()["id"]
video_id = video.json()["id"]
embedded = client.post(
"/api/journal/entries",
headers=headers,
json={
"journal_day_id": day_id,
"entry_id": saved.json()["id"],
"title": "Markttag",
"body": (
"Heute war der Markt voll.\n\n"
f"[[media:{media_id}|Kirschenstand]]\n\n"
"Danach der Hafen.\n\n"
f"[[media:{video_id}|Steg]]"
),
"origin": "user_edit",
},
)
expect(embedded.status_code == 200, "embedded body saved")
expect(f"![Kirschenstand](kansho-media:{media_id})" in embedded.json()["body"], "image token stored as markdown")
expect(f"![Steg](kansho-media:{video_id})" in embedded.json()["body"], "video token stored as markdown")
style_after = client.get("/api/journal/writing-profile", headers=headers).json()
brief = style_after.get("compiled_brief") or ""
sources_blob = " ".join(item.get("body") or "" for item in style_after.get("sources") or [])
expect(media_id not in brief and video_id not in brief, "asset ids stay out of writing brief")
expect(media_id not in sources_blob, "asset ids stay out of style sources")
expect("Kirschenstand" in brief or "Kirschenstand" in sources_blob, "caption remains as wording")
scratch_mark = "SCRATCHTOKEN-NEVER-EGRESS-xyz"
scratch = client.patch(
f"/api/journal/days/{day_id}/scratch",
headers=headers,
json={"items": [{"text": scratch_mark, "done": False}]},
)
expect(scratch.status_code == 200 and scratch.json()["scratch"][0]["text"] == scratch_mark, "scratch saved")
hidden_scratch = client.patch(
f"/api/journal/days/{day_id}/scratch",
headers=other_headers,
json={"items": [{"text": "fremd", "done": False}]},
)
expect(hidden_scratch.status_code == 404, "scratch isolated")
day_after = client.get(f"/api/journal/days/{day_id}", headers=headers).json()
expect(any(item["text"] == scratch_mark for item in day_after.get("scratch") or []), "scratch on day payload")
reset_debug()
turn_scratch = client.post(
f"/api/journal/conversations/{conv['id']}/turn",
headers=headers,
json={"body": "Noch ein Satz zum Abend."},
)
expect(turn_scratch.status_code == 200, "turn after scratch")
from privacy_gateway import debug_last as last_scratch_turn
blob = " ".join(
str(last_scratch_turn.get(key) or "")
for key in ("rendered", "masked", "mask_input", "raw", "reply")
)
intern = (turn_scratch.json().get("trace") or {}).get("intern") or ""
expect(scratch_mark not in blob and scratch_mark not in intern, "scratch not in dialogue egress")
reset_debug()
gen_scratch = client.post(
f"/api/journal/days/{day_id}/generate",
headers=headers,
json={"conversation_ids": [conv["id"]]},
)
expect(gen_scratch.status_code == 200, "generate after scratch")
from privacy_gateway import debug_last as last_scratch_gen
gen_blob = " ".join(
str(last_scratch_gen.get(key) or "")
for key in ("rendered", "masked", "mask_input", "raw", "reply")
)
gen_intern = (gen_scratch.json().get("trace") or {}).get("intern") or ""
expect(scratch_mark not in gen_blob and scratch_mark not in gen_intern, "scratch not in generate egress")
from media_store import strip_jpeg_exif
jpeg = bytearray(b"\xff\xd8\xff\xe1\x00\x10Exif\x00\x00AAAA\xff\xda\x00\x08\x01\x01\x00\x00\xff\xd9")
stripped = strip_jpeg_exif(bytes(jpeg))
expect(b"Exif" not in stripped, "jpeg exif stripped")
print("All MVP journal tests passed.")
finally:
client.__exit__(None, None, None)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,258 @@
"""Detection + mask + demask. Run from backend/: python tests/test_privacy_detect.py"""
from __future__ import annotations
import os
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
os.environ["KANSHO_DB_PATH"] = str(Path(tempfile.gettempdir()) / "kansho-privacy-detect-test.sqlite")
os.environ["KANSHO_FAKE_PROVIDER"] = "1"
os.environ["KANSHO_FAKE_DETECT"] = "1"
Path(os.environ["KANSHO_DB_PATH"]).unlink(missing_ok=True)
from fastapi.testclient import TestClient
from identity_store import list_mappings
from main import app
from privacy_gateway import reset_debug
def expect(ok: bool, message: str) -> None:
if not ok:
raise SystemExit(f"FAIL: {message}")
print(f"OK {message}")
def main() -> None:
reset_debug()
with TestClient(app) as client:
setup = client.post(
"/api/auth/setup",
json={"email": "lars@example.test", "name": "Lars", "password": "test-pass"},
)
headers = {"X-Auth-Token": setup.json()["token"]}
space = client.post("/api/journal/spaces", headers=headers, json={"title": "Alltag"})
day = client.post(
f"/api/journal/spaces/{space.json()['id']}/days",
headers=headers,
json={"calendar_date": "2026-08-22"},
)
conv = client.post(
f"/api/journal/days/{day.json()['day']['id']}/conversations",
headers=headers,
json={"title": "Gespräch"},
)
turn = client.post(
f"/api/journal/conversations/{conv.json()['id']}/turn",
headers=headers,
json={"body": "Ich war mit Anna in Hamburg und danach allein."},
)
expect(turn.status_code == 200, f"turn {turn.text}")
from privacy_gateway import debug_last
expect(debug_last is not None, "gateway recorded request")
expect(debug_last["detect_provider"] == "fake-detect", "detect provider is separate")
expect("Anna" not in debug_last["masked"], "Anna masked before generate")
expect("Hamburg" not in debug_last["masked"], "Hamburg masked before generate")
expect("[[PERSON:" in debug_last["masked"] or "[[PLACE:" in debug_last["masked"], "placeholders in egress")
expect("Anna" in turn.json()["user"]["body"], "source keeps cleartext")
mappings = list_mappings(setup.json()["profile_id"])
labels = {item["local_label"] for item in mappings}
expect("Anna" in labels, "Anna stored in local dictionary")
expect("Hamburg" in labels, "Hamburg stored in local dictionary")
expect(all(not (item.get("token") or "").startswith("[[") or True for item in mappings), "tokens stored")
reset_debug()
again = client.post(
f"/api/journal/conversations/{conv.json()['id']}/turn",
headers=headers,
json={"body": "Anna war ruhig."},
)
expect(again.status_code == 200, "second turn")
from privacy_gateway import debug_last as last2
expect("nicht" in last2["masked"].lower() or "Anna" not in last2["masked"], "dictionary remasks Anna without new invention")
expect(last2["mapping_count"] >= 2, "dictionary reused")
from entity_detect import _pattern_entities
from identity_store import is_maskable_label
from privacy_gateway import _mask
patterned = {item["text"] for item in _pattern_entities("Ich war mit Anna in Hamburg.", set())}
expect("Anna" in patterned and "Hamburg" in patterned, "pattern finds proper names")
expect("Himmel" not in {item["text"] for item in _pattern_entities("Der Himmel war klar.", set())}, "pattern skips weather")
expect(not is_maskable_label("ich"), "pronoun ich is not a mapping")
expect(not is_maskable_label("user"), "role user is not a mapping")
expect(not is_maskable_label("Himmel"), "weather is not identity")
expect(not is_maskable_label("Der Himmel"), "determiner plus weather is not identity")
expect(not is_maskable_label("6:00 Uhr"), "clock time is not identity")
expect(not is_maskable_label("meinen Tee"), "tea is not identity")
expect(not is_maskable_label("Relativ"), "sentence adverb is not identity")
expect(not is_maskable_label("Allerdings"), "conjunction is not identity")
expect(not is_maskable_label("Tages"), "common noun is not identity")
expect(not is_maskable_label("Brote"), "food is not identity")
expect(not is_maskable_label("Überlegen"), "common noun is not identity")
expect(not is_maskable_label("Ortes"), "place word is not identity")
expect(not is_maskable_label("Zwiebeln"), "food is not identity")
expect(not is_maskable_label("letzten"), "function word letzten is not identity")
expect(not is_maskable_label("Getöse"), "collective noun is not identity")
expect(not is_maskable_label("Schiff"), "vessel is not identity")
expect(not is_maskable_label("Faden"), "prompt word is not identity")
expect("Getöse" not in {item["text"] for item in _pattern_entities("Zweimaster mit Getöse im Hafen.", set())}, "mit plus common noun is not a name")
expect("Anna" in {item["text"] for item in _pattern_entities("Ich war mit Anna im Hafen.", set())}, "mit plus given name stays")
expect(is_maskable_label("Sushi"), "given name stays maskable")
expect(is_maskable_label("meine Frau Sushi"), "kinship stripped, name remains")
from identity_store import coalesce_mappings, normalize_token, remember_mapping
from db import get_db
import uuid
expect(normalize_token("PERSON:KURZ") is None, "template token KURZ rejected")
first = remember_mapping(setup.json()["profile_id"], "meine Frau Sushi", "PERSON:08")
second = remember_mapping(setup.json()["profile_id"], "Sushi", "PERSON:KURZ")
expect(first["token"] == second["token"], "alias reuses token")
expect(second["local_label"] == "Sushi", "canonical label is the name")
extra_id = str(uuid.uuid4())
with get_db() as conn:
conn.execute(
"""
INSERT INTO identity_mappings (id, profile_id, token, local_label)
VALUES (?, ?, ?, ?)
""",
(extra_id, setup.json()["profile_id"], "PERSON:KURZ", "meine Frau Sushi"),
)
coalesce_mappings(setup.json()["profile_id"])
sushi_rows = [
item
for item in list_mappings(setup.json()["profile_id"])
if (item.get("local_label") or "").lower() == "sushi"
]
expect(len(sushi_rows) == 1, "coalesce keeps one Sushi row")
expect(sushi_rows[0]["token"].upper() != "PERSON:KURZ", "stable token wins over KURZ")
joined = _mask(
"meine Frau Sushi kam. Sushi stellte fest.",
sushi_rows,
)
expect(joined.count(f"[[{sushi_rows[0]['token']}]]") == 2, "both mentions share one placeholder")
expect("Sushi" not in joined, "name fully masked")
expect("meine Frau " in joined, "kinship stays visible")
homonym = _mask(
"Ich esse gerne Sushi mit meiner Frau Sushi.",
sushi_rows,
)
expect("esse gerne Sushi" in homonym, "food homonym stays")
expect("Frau [[" in homonym or f"Frau [[{sushi_rows[0]['token']}]]" in homonym, "kinship name is masked")
expect(homonym.count("[[") == 1, "only the person mention is masked")
eating = _mask("Heute nur von Sushi essen erzählt.", sushi_rows)
expect("Sushi essen" in eating, "noun before eat stays food")
expect("[[" not in eating, "food-only sentence is not the person")
from pronoun_bind import bind_user_lines, bind_pronouns
from privacy_gateway import mask_for_egress
anna = [{"local_label": "Anna", "token": "PERSON:01"}]
bound = bind_user_lines(
_mask(
"user: Ich war mit Anna.\nuser: Sie setzte sich zu mir, und ich gab ihr Tee.",
anna,
)
)
expect(bound.count("[[PERSON:01]]") == 3, "name and both pronouns share one token")
expect("Sie" not in bound and "ihr" not in bound, "pronouns rebound to person")
journal_masked = mask_for_egress(
"user: Ich war mit Anna.\nuser: Sie setzte sich zu mir, und ich gab ihr Tee.",
anna,
"journal_generate",
)
expect(journal_masked.count("[[PERSON:01]]") == 1, "journal masks the name only")
expect("Sie setzte" in journal_masked and "ihr Tee" in journal_masked, "journal keeps anaphora")
dialogue_masked = mask_for_egress(
"user: Ich war mit Anna.\nuser: Sie setzte sich zu mir, und ich gab ihr Tee.",
anna,
"dialogue_turn",
)
expect(dialogue_masked.count("[[PERSON:01]]") == 3, "dialogue still binds pronouns")
instruction = bind_user_lines(
"user: Anna kam.\n"
"Der Impuls darf eine Frage sein. Er darf keine neue Station setzen.\n"
)
expect("Er darf keine neue Station" in instruction, "instruction pronoun stays")
instruction_name = _mask(
"Nenne Berg nicht in der Anweisung.\nuser: Berg kam vorbei.",
[{"local_label": "Berg", "token": "PERSON:02"}],
personal_lines_only=True,
)
expect("Nenne Berg nicht" in instruction_name, "instruction line is not remasked")
expect("[[PERSON:02]]" in instruction_name, "user line still remasked")
herr = [{"local_label": "Berg", "token": "PERSON:02"}]
male, _ = bind_pronouns(_mask("Herr Berg kam. Er setzte sich.", herr))
expect(male.count("[[PERSON:02]]") == 2, "masculine pronoun follows last man")
address, _ = bind_pronouns(_mask("Anna kam. Seid ihr schon da?", anna))
expect("ihr" in address, "address ihr is not a person")
plural, _ = bind_pronouns(_mask("Anna kam. Sie waren zu dritt.", anna))
expect("Sie waren" in plural or "sie waren" in plural.lower(), "plural sie stays")
croatia = (
"Heute bin ich ca. gegen 6:00 Uhr aufgestanden. Relativ früh gesellte sich "
"auch meine Frau Sushi zu mir. Allerdings sollte die Bootstour erst um 11:00 Uhr starten."
)
patterned_live = {item["text"] for item in _pattern_entities(croatia, set())}
expect("Sushi" in patterned_live, "pattern keeps given name")
expect(
patterned_live <= {"Sushi"},
f"pattern does not treat German function words as names: {patterned_live}",
)
from entity_detect import uses_llm_detect
from providers import ProviderConfig
remote = ProviderConfig(
role="detect",
name="openrouter",
mode="http",
url="https://openrouter.ai/api/v1/chat/completions",
model="x",
key="sk-test",
local=False,
zdr=True,
no_train=True,
)
expect(uses_llm_detect(remote), "development allows remote detect")
os.environ["KANSHO_ENV"] = "production"
expect(not uses_llm_detect(remote), "production blocks remote detect")
local = ProviderConfig(
role="detect",
name="ollama",
mode="http",
url="http://127.0.0.1:11434/v1/chat/completions",
model="x",
key="",
local=True,
zdr=True,
no_train=True,
)
expect(uses_llm_detect(local), "production still allows local detect")
os.environ.pop("KANSHO_ENV", None)
poisoned = _mask(
"Nicht erwähnt ist nicht geschehen. Ich kochte Tee.",
[{"local_label": "ich", "token": "PERSON:05"}],
)
expect("N[[PERSON:05]]t" not in poisoned, "ich does not split nicht")
expect("Nicht erwähnt" in poisoned, "instruction word bleibt")
from privacy_gateway import PrivacyGatewayError, _demask, _validate_response
mapped = [{"local_label": "Anna", "token": "PERSON:01"}]
kept = _validate_response("[[PERSON:01]] war ruhig.", mapped)
expect(kept.startswith("[[PERSON:01]]"), "placeholders pass validation")
expect(_demask(kept, mapped) == "Anna war ruhig.", "demask after validation")
blocked = False
try:
_validate_response("Anna war ruhig.", mapped)
except PrivacyGatewayError as exc:
blocked = exc.code == "response_validation_failed"
expect(blocked, "plaintext identity is blocked before demask")
print("All privacy detect tests passed.")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,289 @@
"""Writing/Interaction profile governance. Run from backend/: python tests/test_profile_governance.py"""
from __future__ import annotations
import os
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
os.environ["KANSHO_DB_PATH"] = str(Path(tempfile.gettempdir()) / "kansho-profile-governance-test.sqlite")
os.environ["KANSHO_FAKE_PROVIDER"] = "1"
os.environ["KANSHO_FAKE_DETECT"] = "1"
Path(os.environ["KANSHO_DB_PATH"]).unlink(missing_ok=True)
from fastapi.testclient import TestClient
from main import app
from interaction_profile_store import propose_observation
from privacy_gateway import reset_debug
def expect(ok: bool, message: str) -> None:
if not ok:
raise SystemExit(f"FAIL: {message}")
print(f"OK {message}")
def header(token: str) -> dict:
return {"X-Auth-Token": token}
def open_space_day(client, headers, title="Alltag", date="2026-08-20"):
space = client.post("/api/journal/spaces", headers=headers, json={"title": title})
expect(space.status_code == 200, f"create space {space.text}")
day = client.post(
f"/api/journal/spaces/{space.json()['id']}/days",
headers=headers,
json={"calendar_date": date},
)
expect(day.status_code == 200, f"open day {day.text}")
return space.json(), day.json()
def facet_map(profile: dict) -> dict:
return {item["facet_key"]: item for item in profile.get("facets") or []}
def trait_map(profile: dict) -> dict:
return {item["slug"]: item for item in profile.get("traits") or []}
def main() -> None:
reset_debug()
with TestClient(app) as client:
setup = client.post(
"/api/auth/setup",
json={"email": "lars@example.test", "name": "Lars", "password": "test-pass"},
)
expect(setup.status_code == 200, "setup")
headers = header(setup.json()["token"])
profile_id = setup.json()["profile_id"]
_space, day = open_space_day(client, headers)
day_id = day["day"]["id"]
saved = client.post(
"/api/journal/entries",
headers=headers,
json={
"journal_day_id": day_id,
"title": "Markt",
"body": "Heute um 7:30 Uhr bin ich zum Markt. Danach habe ich Kirschen gekauft.",
"origin": "user_edit",
},
)
expect(saved.status_code == 200, f"save {saved.text}")
writing = client.get("/api/journal/writing-profile", headers=headers).json()
expect(writing["governance"] == "learning", "default writing governance is learning")
expect(writing.get("lifecycle") == "uninitialized", "save does not confirm the profile")
expect(any(item["kind"] == "journal_entry" for item in writing["sources"]), "saved entry is a source")
expect("Finale Journal Entries" in (writing.get("compiled_brief") or ""), "brief lists saved entries")
expect("Kirschen" in (writing.get("compiled_brief") or ""), "entry wording reaches brief")
expect("rhythm" not in facet_map(writing), "heuristics do not persist as facets")
expect(not trait_map(writing), "save does not invent semantic traits")
locked = client.patch(
"/api/journal/writing-profile/facets/rhythm",
headers=headers,
json={"locked": True},
)
expect(locked.status_code == 200, f"lock {locked.text}")
expect("rhythm" in trait_map(locked.json()), "legacy style key becomes a trait")
expect(trait_map(locked.json())["rhythm"]["locked"] in {True, 1}, "rhythm locked")
other = client.post(
"/api/journal/entries",
headers=headers,
json={
"journal_day_id": day_id,
"title": "Kurz",
"body": "Ok. Gut. Fertig. Punkt. Schluss. Ende. Still. Knapp.",
"origin": "user_edit",
},
)
expect(other.status_code == 200, "second entry")
after_second = client.get("/api/journal/writing-profile", headers=headers).json()
expect(after_second.get("lifecycle") == "uninitialized", "more entries still do not confirm")
expect(trait_map(after_second)["rhythm"]["locked"] in {True, 1}, "locked trait survives later saves")
client.patch(
"/api/journal/writing-profile/facets/rhythm",
headers=headers,
json={"locked": False},
)
frozen = client.patch(
"/api/journal/writing-profile",
headers=headers,
json={"governance": "frozen"},
)
expect(frozen.json()["governance"] == "frozen", "frozen set")
rhythm_frozen = trait_map(frozen.json())["rhythm"].get("statement") or ""
client.post(
"/api/journal/entries",
headers=headers,
json={
"journal_day_id": day_id,
"title": "Anders",
"body": (
"Vielleicht bedeutet das weniger, als es scheint. Allerdings wunderte ich mich. "
"Im Grunde war der Tag merkwürdig und irgendwie offen."
),
"origin": "user_edit",
},
)
still_frozen = client.get("/api/journal/writing-profile", headers=headers).json()
expect(
(trait_map(still_frozen)["rhythm"].get("statement") or "") == rhythm_frozen,
"frozen keeps trait values",
)
advising = client.patch(
"/api/journal/writing-profile",
headers=headers,
json={"governance": "advising"},
)
expect(advising.json()["governance"] == "advising", "advising set")
imported = client.post(
"/api/journal/writing-profile/import",
headers=headers,
json={
"body": "Haha das war irgendwie lustig. Witzig, wirklich witzig, lol.",
"occurred_at": "2019-07-12",
"context_hint": "vacation_diary",
},
)
expect(imported.status_code == 200, f"import {imported.text}")
expect(any(item["kind"] == "imported_text" for item in imported.json()["sources"]), "import stored")
expect(
int(imported.json().get("pending_evidence") or 0) == 0,
"unconfirmed profile does not start continuous review",
)
vacation = next(item for item in imported.json()["sources"] if item["kind"] == "imported_text")
expect((vacation.get("occurred_at") or "").startswith("2019-07-12"), "import keeps time")
expect(vacation.get("context_hint") == "autobiographical_journal", "vacation diary maps to journal facet")
client.patch("/api/journal/writing-profile", headers=headers, json={"governance": "learning"})
conv = client.post(f"/api/journal/days/{day_id}/conversations", headers=headers, json={"title": "Chat"})
turn = client.post(
f"/api/journal/conversations/{conv.json()['id']}/turn",
headers=headers,
json={"body": "Nur ein kurzer Satz ohne Gewicht."},
)
expect(turn.status_code == 200, f"turn {turn.text}")
after_turn = client.get("/api/journal/writing-profile", headers=headers).json()
brief = after_turn.get("compiled_brief") or ""
expect("Finale Journal Entries" in brief, "entries remain strongest after later dialogue")
expect("Formulierungen aus dem Dialog" not in brief, "dialogue excerpts drop once entries exist")
expect("Kirschen" in brief, "earlier journal wording remains")
interaction = client.get("/api/journal/interaction-profile", headers=headers).json()
expect(interaction["governance"] == "advising", "interaction default is advising")
expect(interaction["preferences"] == [], "turn does not invent personal preferences")
expect(interaction["suggestions"] == [], "silence is not an interaction suggestion")
expect(any(item["pref_key"] == "reply_length" for item in interaction["defaults"]), "labeled product defaults")
from privacy_gateway import debug_last
expect("Produktdefault" in (debug_last["rendered"] if debug_last else ""), "hint reaches dialogue prompt")
expect("Ein bis drei Sätze" in (debug_last["rendered"] if debug_last else ""), "length default is in hint not anonymous")
expect("anonyme Produktregel" in (debug_last["rendered"] if debug_last else ""), "prompt labels the moved preference")
proposed = propose_observation(
profile_id,
scope="autobiographical",
pref_key="listening",
proposed_value="Bei längeren Erzählungen zunächst stärker zuhören.",
evidence="Beobachtung, kein automatisches Lernen",
)
expect(proposed and proposed["suggestions"], "observation is a suggestion only")
expect(proposed["preferences"] == [], "observation is not auto-applied")
rejected = client.post(
f"/api/journal/interaction-profile/suggestions/{proposed['suggestions'][0]['id']}/reject",
headers=headers,
)
expect(rejected.json()["suggestions"] == [], "rejected observation stays out of the profile")
personal = client.patch(
"/api/journal/interaction-profile/preferences",
headers=headers,
json={
"scope": "global",
"pref_key": "reply_length",
"value": "Meist nur ein Satz.",
"origin": "explicit",
},
)
expect(personal.status_code == 200, f"personal pref {personal.text}")
expect(personal.json()["preferences"][0]["origin"] == "explicit", "explicit origin kept")
reset_debug()
later = client.post(
f"/api/journal/conversations/{conv.json()['id']}/turn",
headers=headers,
json={"body": "Heute Vormittag zum Markt, danach Kirschen, dann noch der Hafen bei 18:00 Uhr."},
)
expect(later.status_code == 200, "second turn")
from privacy_gateway import debug_last as last_hint
expect("Meist nur ein Satz" in last_hint["rendered"], "personal preference reaches hint")
expect("persönlich" in last_hint["rendered"], "personal override is labeled")
expect(later.json()["conversation"].get("narrative_mode"), "dialogue state remains automatic")
expect("long_story" in later.json()["conversation"], "long_story is conversation state")
manual = client.patch(
"/api/journal/writing-profile/facets/humor",
headers=headers,
json={"value": "trocken, selten, nie aufgesetzt"},
)
expect("humor" in trait_map(manual.json()), "manual humor is a trait")
expect(trait_map(manual.json())["humor"]["origin"] == "manual", "manual trait origin")
expect("trocken, selten" in (manual.json().get("compiled_brief") or ""), "manual trait reaches brief")
exported = client.get("/api/journal/writing-profile/export", headers=headers).json()
expect(exported["kind"] == "kansho.writing_profile", "writing export kind")
expect(exported["format_version"] == 1, "writing export version")
expect(any(item["slug"] == "humor" and "trocken" in (item.get("statement") or "") for item in exported["traits"]), "export keeps manual trait")
expect("profile_id" not in exported, "export is portable")
expect("sources" not in exported, "export is not a journal backup")
client.patch("/api/journal/writing-profile/facets/humor", headers=headers, json={"value": "wird überschrieben"})
restored = client.post("/api/journal/writing-profile/restore", headers=headers, json=exported)
expect(restored.status_code == 200, f"writing restore {restored.text}")
expect(trait_map(restored.json())["humor"]["statement"].startswith("trocken"), "restore replaces traits")
expect(trait_map(restored.json())["humor"]["origin"] == "manual", "restore keeps origin")
wrong = client.post(
"/api/journal/writing-profile/restore",
headers=headers,
json={"kind": "kansho.interaction_profile", "format_version": 1, "preferences": []},
)
expect(wrong.status_code == 400, "writing restore rejects interaction document")
interaction_doc = client.get("/api/journal/interaction-profile/export", headers=headers).json()
expect(interaction_doc["kind"] == "kansho.interaction_profile", "interaction export kind")
expect(interaction_doc["preferences"][0]["value"] == "Meist nur ein Satz.", "interaction export keeps personal pref")
client.patch(
"/api/journal/interaction-profile/preferences",
headers=headers,
json={"scope": "global", "pref_key": "reply_length", "value": "Ganz anders.", "origin": "manual"},
)
interaction_back = client.post("/api/journal/interaction-profile/restore", headers=headers, json=interaction_doc)
expect(interaction_back.json()["preferences"][0]["value"] == "Meist nur ein Satz.", "interaction restore replaces prefs")
bundle = client.get("/api/journal/profiles/export", headers=headers).json()
expect(bundle["kind"] == "kansho.profiles", "bundle kind")
bundle["writing"]["traits"] = [
item if item["slug"] != "humor" else {**item, "statement": "aus dem Sammeldokument", "locked": True}
for item in bundle["writing"]["traits"]
]
both = client.post("/api/journal/profiles/restore", headers=headers, json=bundle)
expect(both.status_code == 200, f"bundle restore {both.text}")
expect(trait_map(both.json()["writing"])["humor"]["statement"] == "aus dem Sammeldokument", "bundle restores writing")
expect(trait_map(both.json()["writing"])["humor"]["locked"] in {True, 1}, "bundle restores lock")
expect(both.json()["interaction"]["preferences"][0]["value"] == "Meist nur ein Satz.", "bundle restores interaction")
print("profile governance ok")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,427 @@
"""Cost-conscious profile review. Run from backend/: python tests/test_profile_review.py"""
from __future__ import annotations
import os
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
os.environ["KANSHO_DB_PATH"] = str(Path(tempfile.gettempdir()) / "kansho-profile-review-test.sqlite")
os.environ["KANSHO_FAKE_PROVIDER"] = "1"
os.environ["KANSHO_FAKE_DETECT"] = "1"
Path(os.environ["KANSHO_DB_PATH"]).unlink(missing_ok=True)
from fastapi.testclient import TestClient
from main import app
from privacy_gateway import reset_debug
from profile_review import (
KIND_PACKAGE,
KIND_RESULT,
facet_delta,
is_strong_edit,
parse_result,
)
def expect(ok: bool, message: str) -> None:
if not ok:
raise SystemExit(f"FAIL: {message}")
print(f"OK {message}")
def header(token: str) -> dict:
return {"X-Auth-Token": token}
def open_space_day(client, headers, title="Alltag", date="2026-08-20"):
space = client.post("/api/journal/spaces", headers=headers, json={"title": title})
expect(space.status_code == 200, f"create space {space.text}")
day = client.post(
f"/api/journal/spaces/{space.json()['id']}/days",
headers=headers,
json={"calendar_date": date},
)
expect(day.status_code == 200, f"open day {day.text}")
return space.json(), day.json()
def trait_map(profile: dict) -> dict:
return {item["slug"]: item for item in profile.get("traits") or []}
def facet_map(profile: dict) -> dict:
return {item["facet_key"]: item for item in profile.get("facets") or []}
def pending_triggers(status: dict) -> set[str]:
return {item.get("trigger") for item in status.get("pending_evidence") or []}
def main() -> None:
expect(not is_strong_edit("Heute Kirschen am Markt gekauft.", "Heute Kirschen am Markt gekauft."), "same text is not a strong edit")
expect(
is_strong_edit(
"Heute war der Markt voll und ich habe Kirschen gekauft.",
"Vielleicht bedeutet das weniger. Allerdings wunderte ich mich den ganzen Abend.",
),
"divergent rewrite is a strong edit",
)
expect(facet_delta({"rhythm": "kurz"}, {"rhythm": "lang", "humor": "trocken"}) == ["rhythm", "humor"], "delta lists changed local signals")
parsed_core = parse_result({
"kind": KIND_RESULT,
"format_version": 1,
"changes": [{"layer": "core", "key": "ignored", "action": "keep", "evidence_ids": []}],
})
expect(parsed_core["changes"][0]["key"] == "core", "core layer maps to core key")
parsed_create = parse_result({
"kind": KIND_RESULT,
"format_version": 1,
"changes": [{
"layer": "trait",
"slug": "scene_cuts",
"facet_key": "autobiographical_journal",
"action": "create",
"proposed_value": "wechselt ohne Ankündigung zwischen Szenen",
"exemplars": [{"excerpt": "Danach der Hafen."}],
"evidence_ids": ["e1"],
}],
})
expect(parsed_create["changes"][0]["slug"] == "scene_cuts", "dynamic trait slugs are accepted")
expect(parsed_create["changes"][0]["action"] == "create", "create remains create")
reset_debug()
with TestClient(app) as client:
setup = client.post(
"/api/auth/setup",
json={"email": "lars@example.test", "name": "Lars", "password": "test-pass"},
)
expect(setup.status_code == 200, "setup")
headers = header(setup.json()["token"])
_space, day = open_space_day(client, headers)
day_id = day["day"]["id"]
first = client.post(
"/api/journal/entries",
headers=headers,
json={
"journal_day_id": day_id,
"title": "Markt",
"body": "Heute um 7:30 Uhr bin ich zum Markt. Danach habe ich Kirschen gekauft.",
"origin": "user_edit",
},
)
expect(first.status_code == 200, "first save")
writing = client.get("/api/journal/writing-profile", headers=headers).json()
expect(writing.get("lifecycle") == "uninitialized", "first save leaves profile unconfirmed")
expect("rhythm" not in {item.get("facet_key") for item in writing.get("facets") or []}, "no heuristic facet bootstrap")
expect(not writing.get("traits"), "no inferred traits")
status = client.get("/api/journal/writing-profile/review", headers=headers).json()
expect(status["pending_evidence"] == [], "first save does not enqueue a review")
second = client.post(
"/api/journal/entries",
headers=headers,
json={
"journal_day_id": day_id,
"title": "Anders",
"body": (
"Haha das war irgendwie lustig, lol. Vielleicht bedeutet das weniger, als es scheint. "
"Allerdings wunderte ich mich. Im Grunde war der Abend merkwürdig und offen."
),
"origin": "user_edit",
},
)
expect(second.status_code == 200, "second save")
status = client.get("/api/journal/writing-profile/review", headers=headers).json()
expect(status["pending_evidence"] == [], "continuous queue waits for confirmed profile")
frozen = client.patch("/api/journal/writing-profile", headers=headers, json={"governance": "frozen"})
expect(frozen.status_code == 200, "frozen")
client.post(
"/api/journal/entries",
headers=headers,
json={
"journal_day_id": day_id,
"title": "Frozen",
"body": "Ganz anderer Ton, witzig, lol, haha, und trotzdem keine Review.",
"origin": "user_edit",
},
)
status = client.get("/api/journal/writing-profile/review", headers=headers).json()
expect(status["pending_evidence"] == [], "unconfirmed frozen still has no queue")
client.patch("/api/journal/writing-profile", headers=headers, json={"governance": "learning"})
draft = client.post(
"/api/journal/entries",
headers=headers,
json={
"journal_day_id": day_id,
"title": "Draft",
"body": (
"Heute war der Markt voll und ich bin früh gegangen und habe Kirschen gekauft "
"und danach den Hafen gesehen, ganz ruhig und ohne Eile."
),
"origin": "accepted_draft",
},
)
expect(draft.status_code == 200, "accepted draft stored")
edited = client.post(
"/api/journal/entries",
headers=headers,
json={
"journal_day_id": draft.json()["journal_day_id"] if False else day_id,
"entry_id": draft.json()["id"],
"title": "Draft",
"body": (
"Der Nachmittag kippte. Ich blieb länger als geplant, sprach kaum, "
"und schrieb später nur zwei knappe Zeilen ohne Markt und ohne Hafen."
),
"origin": "user_edit",
},
)
expect(edited.status_code == 200, "strong user edit")
status = client.get("/api/journal/writing-profile/review", headers=headers).json()
expect("user_edit_of_draft" not in pending_triggers(status), "draft edit waits until the profile is confirmed")
conv = client.post(f"/api/journal/days/{day_id}/conversations", headers=headers, json={"title": "Chat"})
long_body = (
"Heute früh bin ich zum Markt gegangen und habe Kirschen gekauft, danach den Hafen gesehen, "
"später noch Brot geholt, mit jemandem gesprochen, Tee getrunken, den Wind bemerkt, "
"und am Abend den Tag in Ruhe nachvollzogen, ohne etwas zu erzwingen oder nach einem Muster zu suchen. "
"Zwischendurch war eine Pause, dann noch ein kurzer Blick aufs Wasser, und zum Schluss bin ich langsam heimgegangen, "
"ohne den Faden zu verlieren und ohne aus dem Gesprächsverlauf eine dauerhafte Vorliebe zu machen."
)
expect(len(long_body.split()) >= 60, "sample text is long enough")
reset_debug()
long_turn = client.post(
f"/api/journal/conversations/{conv.json()['id']}/turn",
headers=headers,
json={"body": long_body},
)
expect(long_turn.status_code == 200, "long turn")
from privacy_gateway import debug_last as last_long
expect(last_long["purpose"] == "dialogue_turn", "long turn still has no review call")
status = client.get("/api/journal/writing-profile/review", headers=headers).json()
expect("dialogue_sample" not in pending_triggers(status), "dialogue is not sampled before confirm")
corpus = client.post(
"/api/journal/writing-profile/corpus",
headers=headers,
json={
"items": [
{
"body": "Am ersten Urlaubstag um 6:00 Uhr Tee auf dem Balkon, danach der Hafen. Kirschen vom Markt.",
"occurred_at": "2018-07-02",
"context_hint": "vacation_diary",
},
{
"body": "Haha das war irgendwie lustig, lol. Der Abend kippte, ohne dass ich es erklärt hätte.",
"occurred_at": "2024-08-11",
"context_hint": "vacation_diary",
},
{
"body": "Vielleicht bedeutet das weniger. Allerdings wunderte ich mich über die Stille nach dem Markt.",
"occurred_at": "2025-03-04",
"context_hint": "autobiographical_journal",
},
]
},
)
expect(corpus.status_code == 200, f"corpus import {corpus.text}")
dated = [item for item in corpus.json()["sources"] if item.get("kind") == "imported_text"]
expect(len(dated) >= 3, "historical corpus is more than two entries")
expect(any((item.get("occurred_at") or "").startswith("2018-07-02") for item in dated), "older source keeps time")
expect(any(item.get("recency_role") == "long_term" for item in dated), "older text is long-term evidence")
paste = client.post("/api/journal/writing-profile/initial-build/paste", headers=headers)
expect(paste.status_code == 200, f"initial paste {paste.text}")
package = paste.json()["package"]
expect(package["kind"] == KIND_PACKAGE, "paste uses review package contract")
expect(package["format_version"] == 1, "package version")
expect(package["mode"] == "initial_build", "unconfirmed profile opens an initial build")
expect(package["existing_before_new"], "package names Existing-before-New")
expect(any(item.get("kind") == "trait_hint" for item in package["seed_catalog"]), "seed catalog is ordering help")
expect(len(package.get("corpus") or []) >= 3, "initial package includes historical corpus")
expect(any(item.get("occurred_at") for item in package["corpus"]), "corpus items keep time")
instruction = package.get("instruction") or ""
expect("bestehenden Trait bestätigen" in instruction, "instruction starts with confirm")
expect("erst dann einen neuen Trait anlegen" in instruction, "instruction defers create")
expect("autobiographical_journal" in instruction, "vacation/journal facet is named")
expect("kansho.profile_review_result" in (paste.json().get("prompt") or ""), "copy prompt names the result contract")
evidence_id = (package.get("evidences") or [{}])[0].get("id")
imported = client.post(
"/api/journal/writing-profile/review/import",
headers=headers,
json={
"review_id": paste.json()["id"],
"result": {
"kind": KIND_RESULT,
"format_version": 1,
"target": "writing",
"mode": "initial_build",
"changes": [
{
"layer": "trait",
"slug": "dry_humor",
"facet_key": "autobiographical_journal",
"action": "create",
"label": "Trockener Humor",
"proposed_value": "gelegentlich trocken, aus Paste-Review",
"rationale": "Journal-Evidenz, kein globaler Core.",
"evidence_ids": [evidence_id] if evidence_id else [],
"exemplars": [{"excerpt": "haha das war irgendwie lustig", "occurred_at": "2024-08-11"}],
}
],
},
},
)
expect(imported.status_code == 200, f"import result {imported.text}")
expect(imported.json()["applied"] >= 1, "initial build applies traits")
profile = imported.json()["profile"]
expect(profile.get("lifecycle") == "confirmed", "successful initial build confirms the profile")
expect(trait_map(profile)["dry_humor"]["statement"].startswith("gelegentlich trocken"), "created trait is stored")
expect(trait_map(profile)["dry_humor"]["facet_key"] == "autobiographical_journal", "vacation evidence stays in journal facet")
expect(any("haha" in (ref.get("excerpt") or "") for ref in trait_map(profile)["dry_humor"].get("exemplars") or []), "trait keeps exemplar")
expect("core" not in facet_map(profile) or not (facet_map(profile).get("core") or {}).get("value"), "single source kind does not invent a global core")
drift = client.post(
"/api/journal/entries",
headers=headers,
json={
"journal_day_id": day_id,
"title": "Drift",
"body": (
"Haha das war irgendwie lustig, lol. Vielleicht bedeutet das weniger, als es scheint. "
"Allerdings wunderte ich mich. Im Grunde war der Abend merkwürdig und offen."
),
"origin": "user_edit",
},
)
expect(drift.status_code == 200, "drift save after confirm")
status = client.get("/api/journal/writing-profile/review", headers=headers).json()
expect("heuristic_drift" in pending_triggers(status), "after confirm, local heuristic queues evidence")
draft_after = client.post(
"/api/journal/entries",
headers=headers,
json={
"journal_day_id": day_id,
"title": "Draft nach Confirm",
"body": (
"Heute war der Markt voll und ich bin früh gegangen und habe Kirschen gekauft "
"und danach den Hafen gesehen, ganz ruhig und ohne Eile."
),
"origin": "accepted_draft",
},
)
expect(draft_after.status_code == 200, "accepted draft after confirm")
edited = client.post(
"/api/journal/entries",
headers=headers,
json={
"journal_day_id": day_id,
"entry_id": draft_after.json()["id"],
"title": "Draft nach Confirm",
"body": (
"Der Nachmittag kippte noch einmal. Ich blieb, sprach kaum, "
"und schrieb später nur zwei knappe Zeilen ohne Markt und ohne Hafen und ohne Kirschen."
),
"origin": "user_edit",
},
)
expect(edited.status_code == 200, "strong user edit after confirm")
status = client.get("/api/journal/writing-profile/review", headers=headers).json()
expect("user_edit_of_draft" in pending_triggers(status), "strong draft edit is a review trigger")
reset_debug()
long_turn = client.post(
f"/api/journal/conversations/{conv.json()['id']}/turn",
headers=headers,
json={"body": long_body},
)
expect(long_turn.status_code == 200, "long turn after confirm")
expect(long_turn.json()["conversation"].get("preferences") in (None, []), "turn does not write interaction prefs")
status = client.get("/api/journal/writing-profile/review", headers=headers).json()
expect("dialogue_sample" in pending_triggers(status), "long dialogue may be sampled locally")
pending_samples = [item for item in status["pending_evidence"] if item["trigger"] == "dialogue_sample"]
second_long = client.post(
f"/api/journal/conversations/{conv.json()['id']}/turn",
headers=headers,
json={"body": long_body + " Noch ein Nachsatz über den Abend und die Kirschen."},
)
expect(second_long.status_code == 200, "second long turn")
status = client.get("/api/journal/writing-profile/review", headers=headers).json()
samples_now = [item for item in status["pending_evidence"] if item["trigger"] == "dialogue_sample"]
expect(len(samples_now) == len(pending_samples), "dialogue sample has a cooldown")
interaction = client.get("/api/journal/interaction-profile", headers=headers).json()
expect(interaction["preferences"] == [], "dialogue sample is not an interaction preference")
expect(interaction["suggestions"] == [], "silence and flow are not interaction learning")
client.patch("/api/journal/writing-profile", headers=headers, json={"governance": "advising"})
client.post(
"/api/journal/writing-profile/import",
headers=headers,
json={"body": "Witzig, wirklich witzig, haha das war irgendwie lustig, lol, und trotzdem nur Candidate."},
)
reset_debug()
api_review = client.post("/api/journal/writing-profile/review/api", headers=headers)
expect(api_review.status_code == 200, f"api review {api_review.text}")
from privacy_gateway import debug_last as api_trace
expect(api_trace["purpose"] == "profile_review", "api review uses dedicated purpose")
expect(api_trace["layer"] == "profilreview", "trace is not a dialogue turn")
expect(api_review.json()["suggested"] >= 1, "advising queues review as suggestion")
expect(
any("trocken" in (item.get("proposed_value") or "") for item in api_review.json()["profile"]["suggestions"]),
"api result is a suggestion in advising",
)
expect(
"aus Paste-Review" in trait_map(api_review.json()["profile"])["dry_humor"]["statement"],
"advising does not silently overwrite",
)
client.patch("/api/journal/writing-profile", headers=headers, json={"governance": "frozen"})
client.post("/api/journal/writing-profile/review/paste", headers=headers)
humor_before = trait_map(client.get("/api/journal/writing-profile", headers=headers).json())["dry_humor"]["statement"]
frozen_import = client.post(
"/api/journal/writing-profile/review/import",
headers=headers,
json={
"result": {
"kind": KIND_RESULT,
"format_version": 1,
"changes": [
{
"layer": "trait",
"slug": "dry_humor",
"action": "precisify",
"proposed_value": "sollte nicht greifen",
"rationale": "frozen",
"evidence_ids": [],
}
],
}
},
)
expect(frozen_import.status_code == 200, "frozen import accepted as document")
expect(frozen_import.json()["applied"] == 0, "frozen does not apply review")
expect(trait_map(frozen_import.json()["profile"])["dry_humor"]["statement"] == humor_before, "frozen keeps trait")
bad = client.post(
"/api/journal/writing-profile/review/import",
headers=headers,
json={"result": {"kind": "kansho.writing_profile", "format_version": 1, "changes": []}},
)
expect(bad.status_code == 400, "wrong kind is rejected")
print("profile review ok")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,49 @@
"""Local writing-profile compilation. Run from backend/: python tests/test_writing_profile.py"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from writing_profile_store import compile_style_signals
def expect(ok: bool, message: str) -> None:
if not ok:
raise SystemExit(f"FAIL: {message}")
print(f"OK {message}")
def main() -> None:
empty = compile_style_signals(["ok"])
expect(empty == "", "too little text yields no signals")
clocks = compile_style_signals(
["Heute um 7:30 Uhr bin ich zum Markt. Danach habe ich Kirschen gekauft."]
)
expect("Uhrzeiten" in clocks, "clocks become a form hint")
expect("keine Diagnose" in clocks, "signals refuse diagnosis language")
expect("chronologisch" in clocks, "time words mark chronology")
reflect = compile_style_signals(
[
"Der Vormittag war ruhig. Allerdings wunderte ich mich. "
"Vielleicht bedeutet das weniger, als es scheint."
]
)
expect("Einordnung" in reflect, "reflection markers are form, not a self model")
expect(
"keine Diagnose" in compile_style_signals(
["Heute war der Markt voll. Ich bin früh gegangen und habe Kirschen gekauft."]
),
"first-day signals exist without a saved entry",
)
print("writing profile signals ok")
if __name__ == "__main__":
main()

View File

@ -10,4 +10,5 @@ MODULE_VERSIONS = {
"data_layer": "0.1.0",
"dialogue": "0.1.0",
"continuity": "0.1.0",
"journal": "0.1.0",
}

View File

@ -0,0 +1,169 @@
"""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 ""

View File

@ -0,0 +1,146 @@
"""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 ändern",
"zusammenführen oder aufteilen",
"erst dann einen neuen Trait anlegen",
)
TRAIT_ACTIONS = ("confirm", "precisify", "rescope", "merge", "split", "create", "keep", "update")
ACTION_ALIASES = {"keep": "confirm", "update": "precisify"}
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 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
]

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,7 @@ Dieses Dokument dient dazu, für weitere Konzeptarbeit nur die tatsächlich ben
## 1. Root-Dokumente
- `handover.md` Session-Bootstrap und aktueller Arbeitsstand; verweist für Details auf die kanonischen Fachkapitel und ersetzt sie nicht.
- `handover.md` Session-Bootstrap für die **Konzept-/Interviewarbeit**; nicht der Code-Stand. Implementierungsstand und Fit-Gap: `mvp_stand_und_abgleich.md`.
- `fachliche_zielarchitektur.md` Governance, Dokumentationsprinzipien, Querschnittsinvarianten, Dateistruktur.
- `produktvision_und_produktidentitaet.md` Vision, Identität, Scope, Produktprinzipien und Nicht-Ziele.
- `interview_plan.md` Kapitelplan, Interviewmethode, Fortschritt und nächster Interviewblock.
@ -34,6 +34,9 @@ Dieses Dokument dient dazu, für weitere Konzeptarbeit nur die tatsächlich ben
| `reflection_outputs.md` | Journal/Memory/Knowledge/Action Outputs |
| `integrations.md` | Jinkendo-Produktgrenzen und Integrationen |
| `guardrails.md` | Privacy Gateway, Pseudonymisierung, externe KI |
| `mvp.md` | Erster vertikaler Slice (dialoggeführtes Journal) |
| `implementation_foundation.md` | Verbindliche Leitplanke zwischen Zielmodell und Slice |
| `mvp_stand_und_abgleich.md` | **Kanonische Fit-Gap-Analyse** (2026-08-25): Code gegen Foundation, MVP-Slice und Gesamtziel; Gaps, Ungenauigkeiten, Prüfbrief für Gegenlesung |
## 3. Empfohlene Context Bundles
@ -68,6 +71,20 @@ Dieses Dokument dient dazu, für weitere Konzeptarbeit nur die tatsächlich ben
3. `reflection_outputs.md`
4. das jeweils betroffene Fachkapitel
### MVP-Journal-Slice / Fit-Gap
Kanonisches Home der Fit-Gap-Analyse ist `mvp_stand_und_abgleich.md`. Es ersetzt weder `mvp.md` noch die Foundation noch die Produktvision.
**Technische Umsetzung** (wie der Slice gebaut ist, nicht der fachliche Abgleich): `../technical/mvp_implementation.md`.
1. `mvp.md`
2. `implementation_foundation.md`
3. `mvp_stand_und_abgleich.md`
4. `writing_profile_and_journaling.md`
5. `guardrails.md`
6. bei Gegenlesung gegen das Gesamtziel zusätzlich `produktvision_und_produktidentitaet.md`
7. technisch: `../technical/mvp_implementation.md` und `../technical/documentation_index.md` Bundle „MVP-Journal-Slice / technische Umsetzung“
### Datenschutz / externe KI
1. `fachliche_zielarchitektur.md`
@ -82,8 +99,12 @@ Seit 2026-08-19 existiert eine vorläufige technische Rahmenarchitektur unter `.
Sie übernimmt den Produktrahmen **weitgehend von Mitai**. Datenschutz, Privacy Gateway und Guardrails sind fachlich bereits Invariante und technisch bindend. Dialog-, Memory- und MVP-Schnitte bleiben Arbeitsstand; Phasen FI führen sie weiter. Encryption, Löschen und DSFA (Phase H1) sind Ausprägung, nicht Ersatz der Invariante.
Technische Umsetzung des gebauten Journal-Slices: `../technical/mvp_implementation.md`. Fit-Gap bleibt fachlich: `mvp_stand_und_abgleich.md`.
## 5. Ladeprinzip
> **Nicht alle Kanshō-Dokumente gleichzeitig laden.** Root-Dokumente plus die 24 fachlich betroffenen Dateien bilden den Standardkontext.
Code-Stand, Slice-Gaps und Abstand zum Gesamtziel nicht aus einzelnen technischen „Implementierungsstand“-Abschnitten rekonstruieren. Dafür `mvp_stand_und_abgleich.md` laden.
Vor größeren Querschnittsentscheidungen wird zusätzlich über `migration_mapping.md` und die relevanten Invarianten geprüft, ob andere Kapitel betroffen sind.

View File

@ -262,8 +262,13 @@ reflection_outputs.md
integrations.md
guardrails.md
migration_mapping.md
mvp.md
implementation_foundation.md
mvp_stand_und_abgleich.md
```
`mvp_stand_und_abgleich.md` ist das **kanonische Home der Fit-Gap-Analyse**: Code gegen Implementation Foundation, gegen den freigegebenen MVP-Slice und gegen das Gesamtziel. Es ersetzt weder `mvp.md` noch `implementation_foundation.md` noch `produktvision_und_produktidentitaet.md`. Technische Kapitel führen lokale Implementierungsstände; der Abgleich liegt hier.
Die unveränderten Quelldokumente vor der Aufteilung liegen im Verzeichnis `checkpoint_originals/`.
Die **technische** Zielarchitektur liegt parallel unter `docs/architecture/technical/` und beginnt mit `technische_zielarchitektur.md`. Sie ist der Fachdoku nachgeordnet, ersetzt keine Fachkapitel und darf unfertige Interviewblöcke nicht als entschieden darstellen.

View File

@ -15,6 +15,8 @@ canonical_docs_path: "docs/architecture/functional/"
Dieses Dokument ist der **vollständige fachliche Aufsatzpunkt für eine neue Kanshō-Konzept-Session**.
**Implementierungsstand (Code)** steht nicht hier, sondern in `mvp_stand_und_abgleich.md` (2026-08-25): Slice gegen Foundation, MVP-Text und Gesamtziel. Dieses Handover bleibt der Konzept-/Interview-Bootstrap.
Es soll ermöglichen, die Arbeit ohne Rückgriff auf den langen bisherigen Chat fortzusetzen und dabei insbesondere verhindern, dass:
- bereits getroffene Entscheidungen erneut diskutiert werden,

View File

@ -0,0 +1,229 @@
---
title: "Kanshō Implementation Foundation"
status: "Baseline / verbindliche Implementierungsleitplanke"
date: "2026-08-21"
product_family: "Jinkendo"
document_role: "Kompakte Leitplanke zwischen fachlichem Zielmodell und vertikaler Implementierung"
---
# Kanshō Implementation Foundation
## 1. Zweck
Dieses Dokument ist **nicht** die vollständige Kanshō-Spezifikation.
Es enthält nur die Architekturregeln, die ein Implementierungsagent kennen muss, damit ein kleiner MVP nicht durch frühe Vereinfachungen spätere Kernfähigkeiten verbaut.
Die ausführlichen Fachkapitel bleiben Source of Truth für das Zielmodell. Sie sind **Referenz**, aber kein Auftrag, alle dort beschriebenen Fähigkeiten jetzt zu implementieren.
Code-Stand und Abgleich gegen diese Foundation plus `mvp.md` und das Gesamtziel: `mvp_stand_und_abgleich.md` (2026-08-25). Dieses Dokument ersetzt die Foundation nicht.
Leitgedanke:
> **Groß genug denken, um teure Sackgassen zu vermeiden. Klein genug bauen, um früh ein real nutzbares Produkt zu erhalten.**
Für jeden Implementierungsschritt gilt:
> **Implementiere nur den aktuellen Slice. Respektiere dabei die Foundation. Baue das Target Model nicht vorsorglich vor.**
---
## 2. Verbindliche Core-Regeln
### Datenhoheit und Historie
1. **Original Source bleibt kanonisch.**
User Messages, AI Messages und relevante Originaleingaben werden verlässlich erhalten.
2. **Derived ersetzt Source nie.**
Summaries, Insights, Memories, Patterns oder andere Ableitungen dürfen Originalquellen nicht überschreiben oder ersetzen.
3. **Stabile Identitäten von Anfang an.**
Sessions, Messages und die im jeweiligen Slice tatsächlich benötigten langlebigen Fachobjekte erhalten stabile IDs.
4. **Zeit und Provenance sind Core-Daten.**
Relevante Quellen und Ableitungen müssen auf Ursprung und Zeitpunkt zurückführbar sein.
5. **Current Validity und History sind getrennt.**
Eine neue Sicht, Korrektur oder Neubewertung darf einen früheren fachlich relevanten Zustand nicht unkenntlich machen.
6. **User Correction hat Vorrang vor Modellinferenz.**
Eine korrigierte Ableitung darf nicht weiter stillschweigend als aktuelle Wahrheit verwendet werden.
### KI-Ableitungen und persönliches Modell
7. **AI Hypothesis ist kein User Fact.**
Eine vom Modell erzeugte Interpretation wird nicht automatisch zu bestätigtem persönlichem Wissen.
8. **Current expressed state ist kein stabiles Self Model.**
Zeitgebundene Emotionen, Bewertungen oder spontane Selbstbeschreibungen werden nicht automatisch als dauerhafte Identitätsaussagen behandelt.
9. **Model proposes; policy decides; source preserves.**
Das Hauptmodell darf Signale oder Kandidaten liefern; die Anwendung entscheidet über Writes und spätere Promotion.
### Reflection Context
10. **Reflection Space ist ein Bedeutungs- und Kontextobjekt, kein bloßer Ordner.**
11. **Primary und Related Spaces müssen architektonisch möglich bleiben.**
Related Spaces müssen im ersten MVP nicht zwingend sichtbar oder vollständig automatisiert sein.
12. **Assignment ist nicht Identity.**
Mehrfachzuordnung erzeugt keine Kopie desselben fachlichen Objekts.
13. **Existing before new.**
Bestehende langlebige Strukturen werden bevorzugt angereichert, bevor neue erzeugt werden.
### Context und Retrieval
14. **Reflection Space first, Long-Term Memory on demand.**
Der normale Dialog beginnt im aktuellen Reflexionskontext, nicht mit globaler autobiografischer Suche.
15. **Context Builder ist eine eigene Verantwortlichkeit.**
Kontextselektion darf nicht unkontrolliert über Prompts, UI-Komponenten und Featurecode verteilt werden.
16. **Minimum sufficient context.**
Aktiviert wird nur der kleinste ausreichend relevante Kontext für den nächsten sinnvollen Reflexionsschritt.
17. **Retrieval ist Hilfsstruktur, nicht Source of Truth.**
Suchindizes, Embeddings und ähnliche Strukturen müssen austauschbar beziehungsweise rebuildable bleiben.
18. **Provider State ist Optimierung, nicht Memory Ownership.**
Langfristige Dialog- und Memory-Kontinuität bleibt unter Kontrolle von Kanshō.
### Verarbeitung und Erweiterbarkeit
19. **Ein wesentlicher generativer Hauptmodell-Call pro Turn ist der Normalfall.**
Zusätzliche Modellaufrufe sind begründete Ausnahme- oder Enrichment-Pfade.
**Umsetzung:** Der Dialogzug hat einen maskierten Modell-Call (Operation + Impuls). Der Journalentwurf ist ein zweiter, ausdrücklich getrennter Call. Maskierung entscheidet keine Operation. Erzählregister wird lokal gesetzt.
20. **Write Evaluation ist signal-driven, nicht exhaustive.**
Nicht jede Message löst die vollständige Analyse aller möglichen Memory-, Pattern- oder Self-Model-Objekte aus.
21. **Variable Policies nicht quer im Code hardcoden.**
Änderbare Write-, Promotion- oder Routing-Entscheidungen erhalten eine klar erkennbare zentrale Decision-/Policy-Grenze. Eine umfassende Policy Engine ist dafür im MVP nicht erforderlich.
22. **Interne Reichhaltigkeit rechtfertigt keine komplexe UI.**
Die sichtbare Oberfläche bleibt deutlich einfacher als das mögliche interne Zielmodell.
23. **Neue Abstraktionen brauchen aktuellen Nutzen.**
Eine Target Capability wird nicht allein deshalb implementiert, weil sie im Fachkonzept beschrieben ist.
24. **Nicht implementiert ist ein zulässiger Zustand.**
Spätere Fähigkeiten bleiben bewusst offen, solange der aktuelle Entwurf ihre spätere Ergänzung nicht strukturell verbaut.
---
## 3. Foundation Cut vor dem ersten vertikalen Slice
Vor dem ersten echten Produkt-Slice müssen nur die Strukturen vorhanden oder verbindlich entschieden sein, deren spätere Änderung eine teure grundlegende Refaktierung verursachen würde.
### Muss vorhanden sein
- persistente Session- und Message-Quellen,
- stabile IDs,
- Zeitbezug und grundlegende Provenance,
- klare Trennung `Source` / `Derived`,
- Reflection-Space-Grundmodell mit stabiler Identität,
- Primary-Space-Zuordnung,
- Datenmodell darf Related Spaces später zulassen,
- einfache Current-State-/History-Fähigkeit für bereits verwendete veränderliche Objekte,
- Nutzerkorrektur muss Current Validity beeinflussen können,
- zentrale Context-Builder-Schnittstelle,
- austauschbare Retrieval-Schnittstelle,
- Provider-Abstraktion,
- kleine zentrale Write-/Decision-Policy-Grenze.
### Nur bauen, wenn der erste Slice es wirklich benötigt
- Thread,
- Open Question,
- Insight,
- Experience Candidate,
- sichtbare Related Spaces,
- automatische Space-Erkennung,
- semantisches Retrieval,
- Session Summary,
- zusätzliche Enrichment-Verarbeitung.
Das Datenmodell darf diese Fähigkeiten später zulassen; sie müssen nicht vorsorglich funktionsfähig gebaut werden.
---
## 4. Explizite Non-Goals vor dem ersten MVP
Folgende Target Capabilities sind **kein Foundation-Auftrag**:
- vollständiger Reflection Graph,
- Graphdatenbank allein wegen des Reflection-Graph-Konzepts,
- automatische langfristige Pattern-Erkennung,
- ausgereiftes Self Model,
- Landmark-Experience-Engine,
- adaptive Policy- oder Threshold-Learning-Engine,
- automatische komplexe Space-Merges,
- vollständige mindnet-Synchronisation,
- komplexe Resurfacing Engine,
- longitudinale Persönlichkeitsanalyse,
- elf technische Intent Engines,
- umfangreiche Admin-/Governance-Oberfläche,
- Graphvisualisierung,
- komplexe Dashboard-Landschaft.
Ein Coding-Agent darf diese Funktionen nicht vorsorglich implementieren, wenn sie nicht Bestandteil eines ausdrücklich freigegebenen Slices sind.
---
## 5. Arbeitsregel für Coding-Agenten
Vor einer neuen Core-Abstraktion sind drei Fragen zu beantworten:
1. **Braucht der aktuelle Slice diese Abstraktion jetzt?**
2. **Würde ein späteres Nachrüsten ohne diese Vorbereitung eine grundlegende Refaktierung der bereits kanonischen Daten oder Verantwortlichkeiten erzwingen?**
3. **Ist die vorgeschlagene Lösung mit den Core-Regeln dieses Dokuments vereinbar?**
Wenn 1 und 2 beide mit **Nein** beantwortet werden, wird die Abstraktion nicht vorgebaut.
Wenn eine Entscheidung eine Foundation-Regel berührt oder unklar ist, wird gezielt das betreffende Fachkapitel unter `docs/architecture/functional/` konsultiert. Es sollen nicht vorsorglich sämtliche Kanshō-Dokumente in jeden Coding-Kontext geladen werden.
---
## 6. Iterationsprinzip
Die bevorzugte Entwicklung ist vertikal:
> **Foundation → kleiner real nutzbarer Slice → echte Nutzung → Abgleich mit Zielarchitektur → kleinste notwendige Erweiterung**
Das Ziel ist nicht, vor dem ersten Release die gesamte Kanshō-Intelligenz zu implementieren.
Das Ziel ist, **jede kleine Version bereits auf einer Foundation aufzubauen, die die bekannten teuren Sackgassen vermeidet**.
---
## 7. Rolle der Nutzungssituationen / Intents
Die bisher beschriebenen Nutzungssituationen bleiben wertvolle fachliche Szenarien.
Sie sind jedoch:
- kein Critical-Path-Gate für den ersten MVP,
- keine Verpflichtung zu elf technischen Betriebsmodi,
- kein Grund, vor dem ersten Slice eine universelle Intent Engine zu bauen.
Sie dienen später insbesondere als:
- UX-Szenarien,
- Acceptance Cases,
- Architektur-Validierung,
- Testkatalog für neue vertikale Slices.
---
## 8. Nächster Schritt
Nach dieser Foundation wird der erste vertikale MVP-Slice separat spezifiziert.
Aktuell bevorzugter Kandidat:
> **Journal / Daily Reflection als bewusst einfache Day-One-artige Keimzelle auf Kanshō-Foundations.**
Die konkrete Nutzeranwendung, UX, Datenflüsse, In-/Out-of-Scope-Funktionen und Acceptance Criteria gehören in die MVP-Spezifikation und nicht in dieses Dokument.

View File

@ -0,0 +1,951 @@
---
title: "Kanshō MVP"
status: "MVP-Spezifikation / freigegebener erster vertikaler Slice"
date: "2026-08-21"
product_family: "Jinkendo"
document_role: "Konkrete MVP-Spezifikation auf Basis der Implementation Foundation"
---
# Kanshō MVP
## 1. Zweck und Scope
Dieses Dokument spezifiziert den **ersten vertikalen, real nutzbaren Kanshō-Slice**.
Der MVP ist bewusst kleiner als das vollständige Kanshō-Zielmodell. Er soll:
- früh real nutzbar sein,
- die Implementation Foundation respektieren,
- keine später teuren strukturellen Sackgassen erzeugen,
- aber nicht vorsorglich das gesamte Target Model implementieren.
Leitgedanke:
> **Ein dialoggeführtes persönliches Tagebuch, bei dem Kanshō aus dem Gespräch über einen Tag einen Tagebucheintrag im Stil und möglichst mit den Worten des Nutzers erzeugt.**
Der Nutzer kann den erzeugten Eintrag anschließend redigieren, mit Medien anreichern und später chronologisch wiederfinden.
Der besondere Kanshō-Mehrwert gegenüber einem klassischen Journal ist:
> **Der Dialog berücksichtigt den bereits erzählten Tagesverlauf, den aktuellen Reflection Space, relevante frühere Aussagen und die typische Erzählweise des Nutzers, sodass die Gesprächsführung nicht auf generischen Standardfragen basiert.**
---
# 2. Produktversprechen des MVP
Der MVP unterstützt den folgenden Kernablauf:
> **Erzählen → Reflektieren → Tagebucheintrag generieren → redigieren → bewahren → später wiederfinden**
Dabei bleibt der Dialog die primäre Erfassungs- und Reflexionsform.
Der erzeugte Tagebucheintrag ist eine aus dem Dialog abgeleitete, lesbare autobiografische Darstellung.
---
# 3. Sichtbare fachliche Einheiten
## 3.1 Reflection Space
Ein Reflection Space bildet den übergeordneten Kontext.
Beispiele:
- `Lošinj 2026`
- `Kleinwalsertal 2026`
- `Alltag`
Für den MVP ist ein Reflection Space sichtbar und auswählbar.
Ein Reflection Space kann mehrere Journal Days und Journal Entries enthalten.
Reflection Spaces werden im MVP nicht als komplexe Graph- oder Wissensstruktur dargestellt.
### MVP-Funktionen
Der Nutzer kann:
- Reflection Space erstellen,
- Reflection Space umbenennen,
- Reflection Space auswählen,
- chronologische Journal Entries dieses Spaces anzeigen.
---
## 3.2 Journal Day
Der Journal Day ist die **chronologische Bezugseinheit eines Tages**.
Beispiel:
> Donnerstag, 20. August 2026
Wichtig:
> **Journal Day ist nicht Thread.**
Ein Journal Day beschreibt den Tag, über den erzählt wird.
Der Zeitpunkt des Dialogs kann davon abweichen.
Beispiel:
- Journal Day: `2026-08-20`
- Dialogfortsetzung: `2026-08-21 07:15`
Damit kann der Nutzer einen Tag am selben Abend, in mehreren Phasen oder am Folgetag ergänzen.
---
## 3.3 Dialog / Source
Der Originaldialog ist die primäre Quelle.
Er enthält insbesondere:
- User Messages,
- AI Messages,
- Session-/Message-Zeitpunkte,
- Bezug zum Journal Day,
- Bezug zum Reflection Space,
- gegebenenfalls Medien oder weitere Quellen.
Der Originaldialog bleibt erhalten und wird durch generierte Journaltexte nicht ersetzt.
Der Nutzer kann ein Gespräch ausdrücklich aus dem Tag entfernen. Das löscht diese Dialogquelle, nicht einen bereits bewahrten Journal Entry. Eine Zusammenführung mehrerer Gespräche wird nur im Moment der Generierung angeboten, nicht als Dauerzustand auf der Seite.
---
## 3.4 Journal Draft
Ein Journal Draft ist eine von Kanshō aus dem relevanten Dialog erzeugte Fassung.
Er ist:
- Derived,
- noch nicht automatisch die kanonische Nutzerfassung,
- veränderbar,
- erneut generierbar.
Ein neuer Draft darf eine bereits gespeicherte Nutzerfassung nicht stillschweigend überschreiben.
---
## 3.5 Journal Entry
Ein Journal Entry entsteht, sobald der Nutzer eine erzeugte oder bearbeitete Fassung als Tagebucheintrag übernimmt beziehungsweise speichert.
Der Journal Entry:
- besitzt stabile Identität,
- gehört zu einem Reflection Space,
- besitzt einen Journal Day,
- besitzt eine Versionshistorie,
- kann Medien enthalten,
- verweist auf seine zugrunde liegenden Quellen/Dialoge.
Ein Journal Entry bleibt später bearbeitbar.
---
# 4. Mehrere Dialoge am selben Tag
## 4.1 Mehrere Dialogphasen im selben Reflection Space
Mehrere zeitlich getrennte Dialoge können denselben Journal Day betreffen.
Beispiel:
- morgens erste Erzählung,
- abends Fortsetzung,
- am Folgetag Ergänzung.
Sie müssen nicht automatisch einen einzigen Dialogthread bilden.
### Konsolidierungsregel
Wenn mehrere Dialogverläufe:
- dieselbe oder sehr ähnliche Erzählweise besitzen,
- denselben narrativen Typ besitzen,
- vergleichbare Tiefe besitzen,
- inhaltlich eng zusammengehören,
kann Kanshō eine **Konsolidierung zu einem gemeinsamen Tagebucheintrag anbieten**.
Beispiel:
- chronologischer Bericht morgens,
- chronologische Fortsetzung abends.
### Getrennt lassen
Wenn Dialogverläufe deutlich unterschiedliche:
- Tiefen,
- Fokusse,
- Erzählweisen,
- Reflexionsmodi
besitzen, bleiben sie standardmäßig getrennt.
Beispiel:
- chronologischer Urlaubsbericht,
- separate tiefe emotionale Selbstreflexion.
### Verbindliche Regel
> **Konsolidierung wird angeboten, nicht erzwungen.**
---
## 4.2 Gleicher Tag in unterschiedlichen Reflection Spaces
Dialoge oder Journal Entries desselben Kalendertags in unterschiedlichen Reflection Spaces bleiben im MVP **standardmäßig getrennt**.
Beispiel:
- `Lošinj 2026` → chronologischer Urlaubstag
- `Meditationsreise` → tiefe Reflexion über eine Meditation am selben Tag
Eine gemeinsame Quelle oder dasselbe Erlebnis darf später referenziell verbunden werden, ohne die beiden Journal-Kontexte automatisch zusammenzuführen.
---
# 5. Dialogführung
## 5.1 Ziel
Kanshō führt keinen Tagebuch-Fragebogen.
Die Gesprächsführung soll sich wie ein **kontextsensitiver Reflexionspartner** anfühlen.
Der nächste Gesprächsimpuls entsteht aus:
- dem bereits erzählten Teil des Journal Day,
- der aktuellen Erzählweise,
- relevanten früheren Aussagen im Reflection Space,
- bekannten Plänen und Erwartungen,
- wiederkehrenden Aktivitäten,
- auffälligen Veränderungen,
- emotionalen Signalen,
- narrativen Lücken,
- der typischen Erzählweise des Nutzers.
MVP-Umsetzung des Space-Kontexts: keine Content Memory, sondern die letzten gespeicherten Journal Entries desselben Spaces als Recency-Ausschnitte. Der laufende Journal Day bleibt die führende Quelle des Impulses.
---
## 5.2 Allgemeine Dialogoperationen
Für den MVP werden keine Intent-Graphen oder elf technischen Dialogmodi gebaut.
Kanshō kann stattdessen wenige allgemeine Dialogoperationen verwenden:
1. **Fortführen**
Den aktuellen Erzählfluss sinnvoll weiterführen.
2. **Konkretisieren**
Relevante fehlende Person-, Orts-, Zeit- oder Zusammenhangsinformation klären.
3. **Plan oder Erwartung aufgreifen**
Bekannte Vorhaben zeitlich passend vor oder nach dem Ereignis aufgreifen.
4. **Abweichung erkunden**
Eine relevante Veränderung gegenüber bisherigem Verlauf oder wiederkehrender Aktivität vorsichtig ansprechen.
5. **Erleben vertiefen**
Emotion, Wahrnehmung oder persönliche Wirkung aufgreifen, wenn der Nutzer selbst entsprechende Signale setzt.
6. **Bedeutung erkunden**
Einen erkennbar bedeutsamen Punkt weiterführen, ohne künstlich Tiefe zu erzwingen.
Diese Operationen sind keine getrennten Nutzungsmodi.
---
## 5.3 Auswahl-Policy für Folgeimpulse
Kanshō soll den Gesprächsimpuls wählen, der:
> **den aktuellen Erzählmodus respektiert und zugleich den höchsten zusätzlichen autobiografischen oder reflexiven Wert besitzt.**
Dabei gelten für den MVP folgende Regeln:
1. Erzählfluss respektieren.
2. Bei ausführlichem Erzählen wenig unterbrechen.
3. Pro Antwort höchstens einen dominanten Anschlussimpuls setzen.
4. Lokal an das zuletzt Erzählte anschließen.
5. Nicht redundant fragen und nicht nacherzählen: schon Gesagtes nicht als Impuls umformulieren.
6. Starke emotionale oder bedeutungsvolle Signale bevorzugen, wenn der Nutzer selbst diese Ebene geöffnet hat.
7. Bekannte Pläne und Erwartungen zeitlich passend aufgreifen.
8. Relevante Abweichungen vorsichtig formulieren.
9. **Nicht erwähnt bedeutet nicht nicht geschehen.**
10. Ein unspektakulärer Tag darf unspektakulär bleiben.
11. Nicht jede Antwort muss eine Frage enthalten.
12. Generische Standardfragen sind nur Fallback.
13. Bei hoher Erzählaktivität begleitet Kanshō stärker, statt den Nutzer zu interviewen.
14. Ein starker emotionaler oder narrativer Wendepunkt darf einen rein chronologischen Anschluss überstimmen.
15. Bereits ausdrücklich beantwortete emotionale Informationen werden nicht erneut trivial abgefragt; Kanshō kann stattdessen Ursache, Bedeutung oder Spannung vertiefen.
16. Wenn der Nutzer den Tag oder die Szene selbst schließt, hält Kanshō den Schluss. Keine neue Frage, keinen Tagesbogen nacherzählen.
### 5.3.1 Internes Register (MVP-Umsetzung)
Ein zweites generatives Modell nur für Ton und Erzählmodus ist nicht der MVP-Weg. Kanshō bestimmt Dichte, Plan, Erleben und Schluss lokal (`register_hint`) und gibt sie zusammen mit einem Writing-Profile-Hinweis in denselben Dialog-Prompt. Die sichtbaren Operationen bleiben die sechs allgemeinen; keine Intent-Engines.
Die Stimme ist die eines Vertrauten im natürlichen Gesprächsfluss, nicht die einer Maschine, eines Interviewprotokolls oder einer App. Interne Register- und Operationsworte gehören nicht in den Impuls. Für die Tagesreflexion: ein kurzer Impuls zum nächsten Gedanken und abgeleitete Denkanstöße sind erwünscht. Nacherzählen nur bei Widerspruch oder Logikbruch, um ihn zu klären. Lokale Wächter greifen bei erfundenem Vollzug, erfundener Empfindung, bei Nacherzählen ohne solchen Bruch (auch als Zusammenfassung plus Anschlussfrage), bei Systemfloskeln und bei Frage beziehungsweise Tagesbogen nach einem selbst gesetzten Schluss nicht bei einer knappen Anschlussfrage ohne Zusammenfassung. Wenn der Impuls lokal ersetzt wird, lädt Kanshō zur Fortsetzung ein („Ich bin gespannt, wie es weitergeht.“ / „Erzähl bitte weiter.“), statt Anwesenheit zu melden; bei selbst gesetztem Schluss bleibt der Schluss.
### 5.3.2 Drei Schichten (MVP, später wiederverwendbar)
Maskierung, Dialogzug und Journalentwurf sind getrennte Verantwortungen. „Detect“ entscheidet keine Operation. „Generate“ meint im Produkt den Journalentwurf, nicht den Dialogzug.
| Schicht | Aufgabe | Aufruf |
|---|---|---|
| Maskierung | nur schützenswerte Muster (Namen, benannte Orte) | Muster + Wörterbuch; Testphase: OpenRouter-Detect; Produktivziel: lokal / Ollama |
| Dialogzug | eine Operation + ein Impuls, Ton und Tiefe | ein maskierter Modell-Call (`mvp.dialogue_turn`) |
| Journalentwurf | Tagebuchtext aus den Worten der Person | zweiter maskierter Call, nur explizit (`mvp.journal_generate`) |
Die Maskierung gilt unabhängig vom späteren Dialogtyp. Neue Dialoge bekommen eigene Prompts, nicht eine zweite Maskierungs-Logik.
Es gibt keinen dritten Call, der nach der Operation noch einmal antwortet. Intern in der Testspur ist die lokale Klartext-Vorlage des Dialogzugs, nicht ein eigener KI-Call. Egress ist dieselbe Vorlage, maskiert. Die sichtbare Kanshō-Zeile ist das Feld `impulse` aus diesem einen Dialogzug. In der Testphase darf Detect Klartext über OpenRouter sehen; das ist ein bewusster Übergang, nicht der Produktivpfad.
---
## 5.4 Beispiel: bekannter Tagesplan
Wenn am Vortag im selben Reflection Space festgehalten wurde:
> „Morgen wollen wir nach Cres fahren.“
kann Kanshō abhängig vom Zeitpunkt beispielsweise fragen:
### Vor dem Ereignis
> „Heute steht doch eure Fahrt nach Cres an. Seid ihr schon bei den Vorbereitungen?“
oder bei vorher erkennbarer emotionaler Spannung:
> „Heute ist eure Fahrt nach Cres. Gestern klangst du gleichzeitig gespannt und etwas nervös wie fühlt es sich jetzt an?“
### Nach dem Ereignis
> „Heute stand doch eure Fahrt nach Cres an. Wie war sie schließlich?“
---
## 5.5 Beispiel: wiederkehrende Aktivität
Wenn in mehreren bisherigen Morgenbeschreibungen desselben Reflection Space Japanischlernen vorkam und es heute bisher nicht erwähnt wurde, darf Kanshō dies vorsichtig aufgreifen.
Geeignet:
> „Die letzten Tage hast du morgens häufig Japanisch gelernt. Heute ist es bisher noch nicht zur Sprache gekommen. War der Morgen heute anders oder ist es nur noch nicht erwähnt worden?“
Nicht geeignet:
> „Warum hast du heute kein Japanisch gelernt?“
---
## 5.6 Beispiel: emotionaler Bericht
Wenn der Nutzer sagt:
> „Ich war erst richtig sauer und habe dann gemerkt, dass ich eigentlich eher enttäuscht war.“
soll Kanshō nicht erneut trivial fragen:
> „Wie hast du dich gefühlt?“
Sinnvoller kann sein:
> „Was war für dich enttäuschender das konkrete Verhalten oder dass du in diesem Moment etwas anderes erwartet hattest?“
---
# 6. Start eines Journal-Dialogs
Ein neuer Journal-Dialog beginnt nicht zwingend mit:
> „Erzähl mir von deinem Tag.“
Wenn relevanter Kontext verfügbar ist, soll Kanshō daraus einen sinnvolleren Einstieg erzeugen.
Mögliche Kontextquellen:
- bereits bekannte Tagespläne,
- vorher angekündigte Ereignisse,
- laufende Erzählung desselben Journal Day,
- relevante wiederkehrende Aktivitäten im Reflection Space,
- offene narrative Punkte.
Nur wenn kein sinnvoller Kontext verfügbar ist, wird ein neutraler Einstieg verwendet.
Der erste Gesprächsimpuls unterliegt derselben Dialog-Policy wie spätere Folgeimpulse.
---
# 7. Unterbrechen und Fortsetzen
Der Nutzer kann einen Journal-Dialog jederzeit unterbrechen.
Beim späteren Öffnen desselben Journal Day berücksichtigt Kanshō:
- was bereits erzählt wurde,
- welche Abschnitte des Tages schon abgedeckt sind,
- relevante offene narrative Punkte,
- gegebenenfalls inzwischen zeitlich eingetretene geplante Ereignisse.
Eine Fortsetzung am Folgetag bleibt dem ursprünglichen Journal Day zugeordnet, sofern der Nutzer über diesen Tag weitererzählt.
---
# 8. Generierung des Tagebucheintrags
## 8.1 Nutzersteuerung
Die Erzeugung eines Journal Draft erfolgt im MVP **explizit durch den Nutzer**.
Beispiel:
> **Tagebucheintrag generieren**
Kanshō generiert nicht automatisch nach jeder Session einen fertigen Tagebuchtext.
---
## 8.2 Eingaben für die Generierung
Für die Generierung dürfen verwendet werden:
1. die relevanten Originaldialoge des Journal Day, einschließlich der Kanshō-Impulse als Gesprächskontext (nicht als Erlebtes),
2. der aktuelle Reflection-Space-Kontext, soweit er für Verständnis und Kohärenz nötig ist,
3. das Writing Profile,
4. gegebenenfalls ausgewählte eigene Stilbeispiele,
5. bei einer Aktualisierung ein bereits bestehender Draft oder Journal Entry, sofern dies explizit Teil des gewünschten Vorgangs ist.
Im MVP ist der Space-Kontext im Dialogzug die letzten gespeicherten Journal Entries desselben Spaces als kurze Recency-Ausschnitte, keine inhaltliche Space-Memory (keine Threads, keine wiederkehrenden Aktivitäten, keine Relevanzauswahl). Für den laufenden Tag ist der Dialog des Journal Day die führende Quelle. Ein Space Content Memory bleibt später.
---
## 8.3 Generierungsprinzip
Priorität:
> **Eigene Formulierungen des Nutzers erhalten → sprachlich verbinden → Struktur verbessern → Writing Profile anwenden.**
Kanshō soll nicht zuerst den Inhalt abstrakt extrahieren und anschließend vollständig in generischem KI-Stil neu schreiben.
Ein leeres oder nur aus Formhinweisen bestehendes Writing Profile rechtfertigt kein bloßes Durchreichen des Dialogs. Verbinden, Glätten, Absätze und eine Überschrift bleiben Pflicht. Der Quelldialog des zu erzeugenden Tages wird nicht ein zweites Mal als Stil-Exzerpt in den Brief gelegt.
Wenn das Modell den Dialog trotzdem nur durchreicht (gleicher Wortlaut, ein Block), formt eine lokale Nachbereitung Absätze an Phasengrenzen. Sie ändert den Wortlaut nicht und erfindet nichts. Wiederholte Personennamen in einer Passage (nach Demaskierung) werden lokal auf ein- bis zwei Nennungen plus Pronomen zurückgeführt, anhand der Pronomen im user-Text; indirekte Rede wird nicht in den Namen zurückgebogen.
Wenn das Writing Profile substanzielle finale Fassungen oder Importe enthält, kommt die **Formulierung** aus dieser Stimme, der **Inhalt** weiter nur aus den user-Zeilen. Kurze oder stichwortartige Passagen dürfen zu ganzen Sätzen ausformuliert werden; es werden keine Fakten, Gefühle oder Zeiten erfunden. Fehlt das Profile, bleiben Verbinden und Glätten der user-Formulierungen der Weg.
Kanshō-Zeilen im Quelldialog erklären, worauf eine user-Zeile antwortet. Sie werden nicht als Geschehen in den Eintrag übernommen.
### Kanshō darf
- Wiederholungen reduzieren,
- fragmentierte Erzählteile und denselben Erzählstrang verbinden,
- Uhrzeiten und zeitliche Anschlüsse aus dem Gesagten zusammenführen, ohne Zeiten zu erfinden,
- Grammatik und Lesbarkeit glätten,
- chronologische Struktur verbessern,
- sinnvolle Absätze bilden,
- eine Überschrift vorschlagen,
- vom Nutzer selbst eingeführte Reflexionen sinnvoll integrieren.
### Kanshō darf nicht
- Ereignisse erfinden,
- fehlende Details plausibel auffüllen,
- nicht geäußerte Gefühle hinzufügen,
- Motivationen erfinden,
- Unsicherheit in Gewissheit umwandeln,
- den Ton unnötig dramatisieren,
- den Nutzertext in einen generischen KI-Schreibstil überführen,
- Kanshō-Impulse als Geschehen in den Eintrag schreiben,
- Pronomen durch ständige Wiederholung des Namens ersetzen,
- in der indirekten Rede den Namen einsetzen, wo die Person von sich als sie/er spricht.
---
# 9. Writing Profile im MVP
## 9.1 Ziel
Der generierte Journal Entry soll sich zunehmend wie ein eigener Text des Nutzers lesen.
Das Writing Profile darf aus mehreren Quellen entstehen.
### Quellenpriorität
1. **vom Nutzer final redigierte Journal Entries**
2. **vom Nutzer ausdrücklich importierte eigene frühere Texte**
3. **charakteristische Formulierungen und Erzählmerkmale aus dem Dialog**
4. **KI-generierter Draft**
Der KI-generierte Draft ist **keine positive Stilreferenz**, solange er nicht durch den Nutzer als eigene Fassung übernommen beziehungsweise redigiert wurde.
Verbindliche Regel:
> **Finale Nutzerfassungen sind die stärkste Stilquelle. KI-Text darf nicht unbeaufsichtigt auf sich selbst zurücklernen.**
---
## 9.2 Initialisierung
Der Nutzer kann vorhandene eigene Texte bereitstellen.
Diese dienen als Bootstrap-Material für ein erstes Writing Profile.
Der Nutzer muss jedoch nicht zwingend Alttexte importieren.
Ohne Import kann sich das Writing Profile schrittweise aus:
- Dialogstil,
- ersten Journal Entries,
- Nutzerkorrekturen
entwickeln.
---
## 9.3 Lernsignale
Für den MVP können insbesondere berücksichtigt werden:
- bevorzugter Detailgrad,
- chronologische versus stärker thematische Erzählweise,
- typische Satzlänge,
- Grad emotionaler Reflexion,
- Humor und Witz,
- Umgang mit Namen,
- Nutzung von Uhrzeiten und konkreten Details,
- typische Übergänge,
- Wortwahl,
- Verhältnis von sachlicher Beschreibung und Reflexion.
Eine vollständige Persönlichkeitsmodellierung ist dafür nicht erforderlich.
## 9.4 Implementierungsstand
**Status: Code vorhanden (2026-08-25).**
Der Current Brief wird lokal zusammengesetzt, ohne zusätzlichen Modell-Call:
1. Erzählmerkmale als lokale Heuristik aus Nutzertext (Satzlänge, Chronologie, Uhrzeiten, Reflexionsanteil, Humor). Keine Diagnose, kein Self Model.
2. Exzerpte finaler Journal Entries, dann Importe, dann charakteristische `user:`-Zeilen.
3. Ein rollierender `dialogue_style`-Eintrag pro Profil, nach jedem Dialogzug aktualisiert. Vor der Journalgenerierung werden die Quellgespräche dieses Entwurfs aus den Stil-Exzerpten ausgenommen; Formhinweise aus demselben Text dürfen bleiben. Sobald finale Journal Entries existieren, gehen Dialog-Exzerpte nicht mehr in den Brief; sie bleiben höchstens ein Hinweis.
4. Der Dialogzug selbst erhält nur die Formhinweise (Erzählmerkmale), keine Dialog-Exzerpte anderer Tage und nicht den gerade offenen Turn. Exzerpte bleiben der Journalgenerierung vorbehalten.
5. KI-Drafts bleiben ausgeschlossen.
Finale Nutzerfassungen haben höheres Gewicht (mehr und längere Exzerpte, Stimme vor Dialog). Ohne Import ist der erste Generate-Tag nicht stil-leer (Formhinweise), aber nicht eine zweite Kopie desselben Dialogs.
---
# 10. Journal Editor
Nach der Generierung kann der Nutzer den Journal Draft im Bearbeitungsmodus redigieren.
Der Nutzer kann:
- Text ändern,
- Text ergänzen,
- Passagen löschen,
- Absätze formatieren (Absatz, Überschrift, Zwischenüberschrift, Listen),
- markierte Bereiche formatieren (fett, kursiv, unterstrichen),
- Überschrift des Eintrags ändern,
- Bilder und Videos an der Schreibmarke einbetten und im Fließtext verschieben,
- Zeichnungen beziehungsweise Bilddateien ergänzen,
- Bildunterschriften ergänzen.
Der Eintrag wird im Fließtext redigiert und als Markdown gespeichert. Medien liegen als lokale Dateien in `media_assets`; im Text stehen Referenzen `![Unterschrift](kansho-media:<id>)`. Alte Marker `[[media:<id>|…]]` werden beim Speichern nach Markdown überführt. Referenzen gehen nicht an externe Modelle. Unterschriften zählen als Nutzerwortlaut.
Bilder (JPEG, PNG, GIF, WebP) und einzelne Videos (MP4, WebM, MOV) werden im Editor direkt dargestellt. Dateien verlassen die Local Trusted Zone nicht.
Nicht Bestandteil des MVP:
- komplexe Audioverwaltung,
- Videobibliothek oder Schnitt,
- Dokumentarchive,
- Karten-/GPS-Timelines.
Einzelne, vom Nutzer eingebettete Videos im Journaltext sind damit Teil des Slices; eine Videoverwaltung ist es nicht.
---
## 10.1 Tagesstichpunkte (Arbeitsstand)
Während der Tagesreflexion kann der Mensch eine kurze Stichpunktliste **parallel** zum Dialog führen: sich nicht verlieren, Inhalte nicht vergessen, abhaken wenn erzählt.
Das ist **kein** Aufgabenmanager und **keine** Abschluss-Checkliste. Reflexion bleibt das Gespräch.
**Bevorzugte Richtung (2026-08-24):** Die Liste ist lokal, nur für die Person, Klasse A. Sie geht nicht in Dialogzug, Journalentwurf, Writing Profile oder Recency-Ausschnitte. Return erzeugt eine neue leere Zeile und setzt den Cursor dorthin.
**Offen:** Ob eine spätere, bewusst gewählte und maskierte Nutzung als Gesprächskontext sinnvoll ist. Nicht stillschweigend einschalten. Solange unklar, Fail Closed: kein Egress.
---
# 11. Versionierung und Schutz vor unbeabsichtigter Änderung
## 11.1 Version History
Sobald ein Journal Entry gespeichert wurde, werden spätere gespeicherte Änderungen versioniert.
Beispiel:
- v1 generierter Draft / erste übernommene Fassung
- v2 Nutzerredaktion
- v3 Textkorrektur und Bilder
- v4 spätere Ergänzung
Frühere gespeicherte Versionen bleiben wiederherstellbar. Die aktuell geladene Fassung darf in der Liste stehen, wird dort aber nicht wiederhergestellt. Offene, noch nicht gespeicherte Editoränderungen können lokal verworfen werden („Alle Änderungen rückgängig machen“). Verlassen des Editors mit solchen Änderungen wird bestätigt, nicht still verworfen.
---
## 11.2 Kein stilles Überschreiben
Kanshō darf eine gespeicherte Nutzerfassung nicht automatisch durch eine neue KI-Fassung ersetzen.
Eine erneute Generierung erzeugt:
- einen neuen Draft,
- oder eine neue vorgeschlagene Version.
Die bestehende Journal-Version bleibt erhalten, bis der Nutzer eine Änderung ausdrücklich übernimmt. Die Oberfläche öffnet den neuen Entwurf (`?draft=1`), nicht still die gespeicherte Fassung. Übernehmen schreibt eine neue Version des bestehenden Entry; Speichern aus dem Entwurf kann auch einen weiteren Entry anlegen.
---
## 11.3 Löschen
Versehntliches endgültiges Löschen soll verhindert werden.
Für den MVP:
- Entfernen eines Journal Entry führt zunächst zu Soft Delete / Papierkorb,
- endgültiges Löschen ist eine separate explizite Aktion,
- Version History schützt zusätzlich vor unbeabsichtigten Textänderungen.
---
# 12. Journal als Nachschlagewerk
## 12.1 MVP-Navigation
Retrieval wird im ersten MVP bewusst klein gehalten.
Der Nutzer erhält primär Navigation statt intelligenter Langzeitsuche.
Mindestens verfügbar:
- chronologische Liste der erzeugten Journal Entries,
- Navigation nach Reflection Space,
- Datum,
- Journal Entry öffnen,
- vom Journal Entry zum zugrunde liegenden Quelldialog wechseln.
---
## 12.2 Nicht im MVP
Noch nicht erforderlich:
- semantische Freitextsuche über die gesamte Biografie,
- Natural-Language-Questions über Jahre,
- Personen- oder Themencluster,
- automatische Jahresrückblicke,
- komplexes Source-oriented Retrieval,
- Graphnavigation.
Die technische Foundation soll späteres Retrieval ermöglichen, ohne es jetzt vollständig zu implementieren.
---
# 13. MVP-Screens
Der MVP soll bewusst wenige sichtbare Bereiche besitzen.
## 13.1 Journal / Reflection Spaces
Zeigt:
- verfügbare Reflection Spaces,
- chronologische Journal Entries des ausgewählten Spaces,
- Möglichkeit, neuen Space anzulegen,
- Möglichkeit, bestehenden Space umzubenennen,
- Möglichkeit, einen Journal Day zu öffnen oder zu starten.
---
## 13.2 Journal Day / Dialog
Zeigt:
- Bezugstag,
- Reflection Space,
- bisherigen Dialog,
- Eingabefeld,
- optionale persönliche Stichpunkte parallel zum Gespräch (nicht KI-Kontext, siehe §10.1),
- Medienoptionen soweit für Dialogquellen nötig,
- Aktion `Tagebucheintrag generieren`,
- vorhandenen Draft-/Entry-Status.
Der Dialog soll sich wie ein natürlicher Chat anfühlen.
---
## 13.3 Journal Editor
Zeigt:
- generierten beziehungsweise gespeicherten Journaltext,
- Überschrift,
- Fließtexteditor mit Absatz- und Auswahlformaten,
- eingebettete Bilder und Videos samt Unterschrift,
- Speichern,
- Versionshistorie,
- gegebenenfalls neue Draft-Fassung übernehmen.
---
## 13.4 Source Dialog
Vom Journal Entry kann der Nutzer auf den zugrunde liegenden Originaldialog wechseln.
Diese Ansicht dient:
- Nachvollziehbarkeit,
- persönlichem Nachlesen,
- Provenance,
- späterem Re-Grounding.
Eine komplexe technische Provenance-UI ist im MVP nicht erforderlich.
---
# 14. Minimaler technischer/fachlicher Datenbedarf des Slices
Die konkrete technische Implementierung bleibt der technischen Architektur vorbehalten.
Der MVP benötigt fachlich mindestens:
- User
- Reflection Space
- Journal Day
- Session / Dialog
- Message / Source
- Journal Draft
- Journal Entry
- Journal Entry Version
- Media Reference
- grundlegende Source References
- Writing Profile beziehungsweise Writing-Style-Kontext
Threads, Open Questions, Experiences und Insights müssen für den ersten Slice nicht als sichtbare oder vollständige produktive Objektmodelle implementiert werden.
---
# 15. MVP Acceptance Cases
## A. Einfacher Journal Day
Der Nutzer führt einen Dialog über seinen Tag.
Er löst `Tagebucheintrag generieren` aus.
Kanshō erstellt einen lesbaren Journal Draft.
Der Nutzer korrigiert einige Formulierungen und speichert den Journal Entry.
**Bestanden, wenn:**
- Originaldialog erhalten bleibt,
- Journal Entry lesbar und quellentreu ist,
- Nutzeränderungen gespeichert werden,
- der Entry chronologisch im Reflection Space erscheint.
---
## B. Mehrphasiger Tag
Der Nutzer:
- erzählt morgens,
- setzt den Dialog abends fort,
- ergänzt am Folgetag noch einen Punkt.
Alle Beiträge beziehen sich auf denselben Journal Day.
**Bestanden, wenn:**
- Journal Day und tatsächliche Message-Zeitpunkte getrennt bleiben,
- alle relevanten Quellen einem Journal Entry zugrunde liegen können,
- genau der gewünschte Tag chronologisch angezeigt wird.
---
## C. Kontextsensitiver Dialog
Ein Tagesplan wurde zuvor im selben Reflection Space erwähnt.
Oder eine Aktivität ist in mehreren vorherigen Tagen wiederholt aufgetreten.
**Bestanden, wenn:**
- Kanshō einen relevanten Plan oder eine relevante Abweichung sinnvoll aufgreifen kann,
- keine generische Standardfrage nötig ist,
- Kanshō `nicht erwähnt` nicht mit `nicht geschehen` verwechselt.
---
## D. Ausführliche Erzählung
Der Nutzer erzählt mehrere Absätze mit hoher Informationsdichte.
**Bestanden, wenn:**
- Kanshō nicht jede Einzelinformation mit einer Frage beantwortet,
- höchstens ein dominanter Folgeimpuls gesetzt wird,
- der Erzählfluss nicht unnötig unterbrochen wird.
---
## E. Emotionaler Abschnitt
Der Nutzer benennt selbst eine emotionale Veränderung oder Bedeutung.
**Bestanden, wenn:**
- Kanshō keine bereits beantwortete triviale Gefühlsfrage wiederholt,
- eine Vertiefung nur erfolgt, wenn sie zum Erzählfluss passt,
- Kanshō keine zusätzliche emotionale Bedeutung erfindet.
---
## F. Unterschiedliche Dialogtypen am selben Tag
Am selben Tag entstehen:
- ein chronologisch-autobiografischer Dialog,
- ein deutlich tieferer emotionaler Reflexionsdialog.
**Bestanden, wenn:**
- Kanshō diese nicht automatisch konsolidiert,
- der Nutzer getrennte Journal Entries beziehungsweise Darstellungen behalten kann.
---
## G. Ähnliche Dialoge am selben Tag
Morgens und abends entstehen zwei chronologisch zusammengehörige Dialogphasen.
**Bestanden, wenn:**
- Kanshō eine Konsolidierung anbieten kann,
- der Nutzer entscheidet,
- keine Konsolidierung erzwungen wird.
---
## H. Unterschiedliche Reflection Spaces
Am selben Tag existieren Inhalte in zwei Reflection Spaces.
**Bestanden, wenn:**
- beide standardmäßig getrennt bleiben,
- keine automatische Zusammenführung allein aufgrund des Datums erfolgt.
---
## I. Persönlicher Stil
Der Nutzer hat eigene frühere Texte importiert und/oder bereits Journal Entries redigiert.
**Bestanden, wenn:**
- finale Nutzerfassungen stärker gewichtet werden als KI-Drafts,
- typische Erzählmerkmale erkennbar berücksichtigt werden,
- eigene Formulierungen aus dem Dialog soweit sinnvoll erhalten bleiben,
- der erzeugte Text nicht in generischen KI-Stil driftet.
---
## J. Versionsschutz
Der Nutzer bearbeitet einen bestehenden Journal Entry mehrfach.
**Bestanden, wenn:**
- frühere gespeicherte Fassungen erhalten bleiben,
- eine frühere Version wiederhergestellt werden kann,
- eine neue KI-Generierung die aktuelle Nutzerfassung nicht stillschweigend überschreibt.
---
## K. Nachschlagen
Der Nutzer möchte einen früheren Urlaubstag wieder ansehen.
**Bestanden, wenn:**
- der Eintrag über Reflection Space und chronologische Liste auffindbar ist,
- er nach Datum geöffnet werden kann,
- vom Entry auf den Quelldialog gewechselt werden kann.
---
# 16. Explizite Non-Goals des MVP
Nicht Bestandteil dieses MVP sind:
- sichtbares Self Model,
- langfristige Pattern-Ansicht,
- Insights-Verwaltung,
- sichtbare Thread-Verwaltung,
- Open-Question-Verwaltung,
- vollständiger Reflection Graph,
- automatische Lebensanalyse,
- Values-Ansicht,
- komplexe Resurfacing Engine,
- mindnet-Frontend,
- elf technische Intent Engines,
- Mood Tracking,
- Habit Tracking,
- Gamification,
- komplexe Dashboards,
- Kalender-/Taskmanagement,
- semantische Langzeitsuche,
- Natural-Language-Biografieabfragen,
- automatische Jahres- oder Lebensrückblicke,
- komplexe Admin-/Governance-Oberfläche,
- automatische langfristige Persönlichkeitsanalyse.
Kanshō darf im Dialog einfache relevante Wiederholungen oder Abweichungen erkennen, ohne daraus bereits ein sichtbares Habit-, Pattern- oder Self-Model-Modul zu machen.
---
# 17. Scope-Freeze
Mit dieser Spezifikation gilt der erste Kanshō-MVP fachlich als ausreichend definiert.
Neue Funktionen werden vor Beginn der Implementierung nur aufgenommen, wenn:
1. sie für einen der beschriebenen Acceptance Cases zwingend erforderlich sind,
2. ihr späteres Nachrüsten sonst eine grundlegende Refaktierung kanonischer Daten oder Verantwortlichkeiten verursachen würde,
3. oder ein echter Widerspruch zur Implementation Foundation entdeckt wird.
Ansonsten gilt:
> **Nicht weiter horizontal konzipieren. Den vertikalen Slice bauen, real nutzen und aus der Nutzung lernen.**
Der nächste Schritt ist die Ableitung eines konkreten technischen Implementierungsplans aus:
- `implementation_foundation.md`
- dieser `mvp.md`
- und nur den jeweils dafür benötigten kanonischen Fachkapiteln.
Der erste vertikale Slice ist im Code vorhanden. Der Abgleich von Code gegen diese Spezifikation, die Foundation und das Gesamtziel steht in `mvp_stand_und_abgleich.md` (2026-08-25). Er ersetzt diese Spezifikation nicht.

View File

@ -0,0 +1,567 @@
---
title: "Kanshō MVP-Stand und Abgleich gegen Foundation, Slice und Gesamtziel"
status: "Abgleich / Arbeitsstand"
date: "2026-08-25"
product_family: "Jinkendo"
document_role: "Ist-Stand des ersten vertikalen Slices gegen Implementation Foundation, MVP-Spezifikation und Produktziel; Gaps und Ungenauigkeiten"
parent_document: "mvp.md"
---
# Kanshō MVP-Stand und Abgleich
**Stand:** 2026-08-25, gegen den lokalen Code unter `frontend/` und `backend/`, inklusive Architecture-Correction-Pass am selben Tag.
Dieses Dokument **ersetzt** weder `mvp.md` noch `implementation_foundation.md` noch `produktvision_und_produktidentitaet.md`. Es sagt, was gebaut ist, was welche Spezifikation verlangt, und wo Doku, Tests oder UI den Code überzeichnen.
Es ist der **Prüfbericht für eine unabhängige Gegenlesung**: Slice gegen freigegebenen MVP-Text, Foundation gegen teure Sackgassen, Code gegen das langfristige Gesamtziel. Der Slice darf das Gesamtziel nicht erfüllen; er darf es nicht verbauen und nicht als schon erreicht verkaufen.
---
# 0. Für den prüfenden Agenten
## 0.1 Auftrag dieses Dokuments
Ein zweiter Agent soll damit prüfen:
1. ob der gebaute Slice zum **Gesamtziel** (Personal Reflection Companion) noch als Keimzelle passt oder schon als Tagebuch-App driftet,
2. ob die **Implementation Foundation** gehalten wird,
3. ob der **freigegebene MVP-Text** in `mvp.md` eingelöst, untererfüllt oder überzeichnet ist,
4. welche Lücken **Slice-Gaps** sind (jetzt schließen oder Spec korrigieren) und welche **Zielmodell-Lücken** (bewusst später).
Nicht Auftrag: das Target Model nachbauen, den Slice horizontal erweitern, oder offene Fachinterviews als Implementierungsauftrag lesen.
## 0.2 Drei Ebenen, nicht vermischen
| Ebene | Quelle | Frage |
|---|---|---|
| **Gesamtziel** | `produktvision_und_produktidentitaet.md`, Guardrails, Memory/Self/Dialog-Kapitel | Wird Kanshō der dialogische Reflexionsraum der Jinkendo-Familie? |
| **Erster Slice** | `mvp.md` + `implementation_foundation.md` | Ist ein dialoggeführtes Journal auf der Foundation real nutzbar, ohne Sackgassen? |
| **Code** | `frontend/`, `backend/` | Was tut die laufende Anwendung tatsächlich? |
Der MVP-Leitgedanke in `mvp.md` §1 ist bewusst kleiner als die Vision: *dialoggeführtes persönliches Tagebuch, Entwurf aus dem Gespräch*. Die Vision in der Produktidentität ist größer: *Wahrnehmen → Reflektieren → Verstehen → Einordnen*, langfristige Kontinuität, Werte, offene Fäden, vorsichtige Zusammenhänge. **Beides ist wahr.** Der Slice ist die Keimzelle, nicht das Produkt.
## 0.3 Was laden (Context Bundle)
Pflicht:
1. dieses Dokument (Fit-Gap)
2. `mvp.md`
3. `implementation_foundation.md`
4. `produktvision_und_produktidentitaet.md` (Identität, Prinzipien, Nicht-Ziele)
5. `guardrails.md` (Invariante Datenschutz)
6. **technische Umsetzung:** `../technical/mvp_implementation.md`
7. Vertiefung: `../technical/privacy_gateway.md` §9.19.2, `../technical/memory_storage_and_offline.md` §6.2, `../technical/frontend_pwa_shell.md` §6.1, `../technical/backend_and_api.md` §5.1
Nur bei Bedarf, nicht vorsorglich den ganzen Ordner:
- `writing_profile_and_journaling.md` Stil und zweistufige Generierung (Ziel, nicht Slice)
- `dialogue_model.md` Contextual Continuation als Default-IA
- `memory_and_context.md` Schichten Working / Thread / Episodic
- `reflection_outputs.md` Journal vs Memory vs Knowledge vs Action
- `integrations.md` Kairo / mindnet / Obsidian
## 0.4 Was der prüfende Agent nicht tun soll
- Foundation-Regel 24 ignorieren („nicht implementiert ist zulässig“) und fehlendes Self Model, Resurfacing oder mindnet als Slice-Bug werten.
- Recency-Ausschnitte (letzte fünf Entries × 400 Zeichen) als semantisches Gedächtnis oder Space Content Memory lesen.
- Admin-Testspur, Prompt-DB und `/dialog` als produktive Nutzer-IA lesen.
- Acceptance-Tests C/D/E als Beweis für Begleiter-Qualität lesen; sie sind oft Prompt- oder Call-Proxies.
- Detect über OpenRouter als erfüllte Guardrail-Produktivlage lesen; das ist Testphase.
- Empfehlen, das Target Model in diesem Slice vorzubauen.
## 0.5 Statuswörter
| Wort | Bedeutung |
|---|---|
| **Erfüllt** | Anforderung im Code und in der nutzbaren Oberfläche vorhanden |
| **Teilweise** | Kern da, fachliche Schärfe oder UI fehlt |
| **Gap** | Anforderung der jeweiligen Ebene nicht erfüllt |
| **Ungenauigkeit** | Spezifikation, Test oder Kommentar behauptet mehr als der Code leistet |
| **Bewusst offen** | Non-Goal, später, oder Foundation-Regel 24 |
| **Abweichung** | weicht vom technischen Zielrahmen ab, ohne die Foundation zu brechen |
| **Spannung** | Slice und Gesamtziel ziehen auseinander, ohne dass der Slice deshalb falsch ist |
---
# 1. Was der Slice heute ist
Kanshō ist lokal ein dialoggeführtes Journal auf FastAPI + SQLite und einer React/Vite-PWA-Shell.
**Kernablauf, der geht:** Space anlegen → Journal Day öffnen → Dialog führen → Entwurf explizit erzeugen → redigieren, Medien im Text, speichern als neue Version des bestehenden Eintrags (optional eigener Eintrag) → Versionen wiederherstellen oder lokale Edits verwerfen → Quelldialog öffnen. Parallel: lokale Stichpunkte (Checkliste), die Kanshō nicht sieht; Return erzeugt eine neue Zeile und setzt den Cursor dorthin. Ungespeicherte Editoränderungen werden beim Verlassen bestätigt.
**Technische Keimzelle:** Auth-Sessions, Prompt-DB, Platzhalter, Feature-Check, Privacy Gateway, zwei Provider-Rollen (Maskierung / Sprachmodell), Context Builder, Retrieval als SQL-Schnittstelle, Writing Profile ohne Extra-LLM, Pronomenbindung nur im Dialogzug.
**Nicht gebaut und nicht behauptet als gebaut:** Space Content Memory, Threads-UI, semantisches Retrieval, Self Model, Insights, Related Spaces in der Nutzung, Papierkorb-UI, PostgreSQL, lokales Detect-Modell, Voice, Offline-Sync, Obsidian/mindnet/Kairo-Handoff, Contextual Continuation als Start, zweistufige Journalgenerierung.
**Produktidentität im Slice:** der Kern ist *Erzählen → reflektierender Impuls → Journalentwurf*. Das ist eine legitime Keimzelle von „Personal Reflection Companion“, noch kein Begleiter über Monate, keine Wertearbeit, keine offenen Fäden.
---
# 2. Delta seit 2026-08-24
Additiv zum vorigen Abgleich, nicht an seiner Stelle:
| Thema | Änderung im Code | Wirkung auf den Abgleich |
|---|---|---|
| Generate → Entwurf | UI öffnet `?draft=1`, nicht still den gespeicherten Entry | Case J / §11.2 klarer erfüllt |
| Speichern aus Entwurf | Default: neue **Version desselben** Entry (`accepted_draft`); optional eigener Eintrag | Keine stillen Doppel-Entries mehr als Normalpfad |
| Titel | `clean_title` / `entryTitle` entfernen führendes `#` | Editor und Versionen zeigen denselben Titel |
| Versionen-UI | Aktuelle Fassung in der Liste als **Aktuell**, ohne Restore. Bei Dirty: **Alle Änderungen rückgängig machen** (lokal, keine DB-Version) | §11.1 eingelöst |
| Dirty-Flag | Snapshot nach Laden/Speichern; In-App-Navigation, Abmelden, Tab-Schließen fragen nach | Schutz vor stillem Verlust; Browser-Zurück nicht hart blockiert |
| Writing Profile | Finale Entries stärker (mehr/längere Exzerpte); Dialog-Exzerpte entfallen, sobald Entries existieren | §9 Priorität näher am Text |
| Journal-Prompt / Shape | Szenen verbinden, Stimme aus dem Brief; lokale Namensausdünnung nach Demask (`naturalize_person_mentions`) | Qualität modellabhängig, Shape nur lokal |
| Pronomen | Bindung `er`/`sie` nur im **Dialogzug**; Journalentwurf ungebunden, sonst Demask-Klarnamen-Flut | Guardrail-fein, nicht Identity-Mapping |
| Stichpunkte | Return erzeugt leere Folgezeile **und** fokussiert sie; „Punkt hinzufügen“ ebenso | UX von §10.1 |
| `local_hold` | Fallback-Impuls nicht mehr „Ich bin noch da“, sondern Fortsetzungsbitte | Dialogqualität, kein Architekturwechsel |
Historische Doppel-Entries aus der Zeit, als Speichern aus dem Entwurf neue Einträge anlegte, bleiben in der DB getrennt. Das ist Altbestand, kein aktueller Normalpfad.
---
# 2.1 Architecture Correction Pass (2026-08-25)
Additiv zu Abschnitt 2. Kein horizontaler Slice, kein Target-Model-Vorbau. Korrigierter **Ist-Stand** vs. bewusst offene Target-Fähigkeiten:
| Thema | Korrigierter Ist-Stand | Bewusst offen |
|---|---|---|
| Provenance Draft/Version | Relationale `journal_*_source_refs`; JSON-Listen werden verlustfrei migriert. Source unverändert. | Provenance-UI, Re-Grounding |
| Context Selection | Context Builder fordert Specs (`day_messages`, `space_entries`, `space_recent_sources`, `writing_profile`). Recency ist Retrieval-Implementierung, keine fachliche API. | Semantisches Retrieval, Minimum-sufficient nach Relevanz |
| Space-Source im Dialogzug | Begrenzte jüngere Original-Conversations desselben Space; aktuelle Conversation bleibt primär; Geschwister desselben Days werden nicht pauschal voll geladen. | Globales/semantisches Retrieval, Session-Summaries |
| Detect | Externes Klartext-Detect nur Development/Test. Produktiv: Muster oder lokale Detect-URL. | Lokales Detect-Modell (Ollama) |
| Response Validation | Blockiert Klartext-Identität in der Rohantwort vor Demask. | Quasi-Identifikatoren, Platzhalter-Vollständigkeit, Audit |
| Konsolidierung | Lokale Signale `narrative_mode` / `reflection_depth` / `current_focus` aus dem Hauptcall, kein Extra-LLM. Angebot nur bei hinreichender Ähnlichkeit; Nutzer entscheidet. | Narrative Typ-Engine, UI-Feinheiten |
| `/dialog` | Nicht in der Produkt-Nav; Admin-only Dev/Test-Harness. | Start = offener Journal Day (Stub-Home bleibt) |
| Writing vs Interaction | `WritingProfile` nur Journal-Stil. Dialogue Kernel nutzt `register_hint` plus leeren `interaction_hint`-Slot. | Interaction-Profile-Engine, UI, Persistenz |
| Reflection Space | Conversation kann `space_id` ohne `journal_day_id` tragen. Space ist nicht Journal-Container. | Space-Typen, Related Spaces, Intents pro Space |
| Dialogue Kernel | Sechs Operationen bleiben allgemeine Gesprächsoperationen. Konsolidierung und Tages-Coverage liegen in Journal-Policy/Context. | Intent-Engines, Contextual Continuation |
Tests: `backend/tests/test_architecture_correction.py` plus Anpassungen in `test_mvp_journal.py` und `test_privacy_detect.py`.
---
# 2.2 Profile Governance (2026-08-25)
Additiv zu 2.1. Keine neue allgemeine Architekturphase.
| Thema | Ist-Stand | Bewusst offen |
|---|---|---|
| Writing Profile | Facets + Governance `learning`/`advising`/`frozen`. Brief abgeleitet. Bootstrap aus gespeicherten Entries. Kein Blind-Overwrite. | Große Profile-UI, zeitliche Versionierung des Fachkapitels |
| Interaction Profile | Persistenz, gelabelte Produktdefaults, Hint-Slot, Default `advising`. Lernen nur explizit/manuell/angenommener Vorschlag. | Volle Feedback-UI, Analytics, Intent-Engine |
| Dialogue State | Bleibt automatisch; zusätzlich `emotional_intensity`, `long_story` | already_covered, known_plan, Intent-Taxonomie |
| JSON-Portabilität | Export/Restore von Writing-, Interaction- oder Sammeldokument (`format_version` 1). Restore ist ausdrücklich und ersetzt Facets/Prefs. | Vollständiger Account-Export, Journal-Portabilität |
Persönliche Längen-/Impulsregeln liegen nicht mehr anonym im Dialog-Prompt, sondern im sichtbaren Interaction-Hint.
Tests: `backend/tests/test_profile_governance.py`.
---
# 2.3 Profile Review Pipeline (2026-08-25)
Additiv zu 2.2. Keine Review-Calls nach jedem Dialog, keine neue große Profil-Engine.
| Thema | Ist-Stand | Bewusst offen |
|---|---|---|
| Writing Review | Lokale Drift/Evidence, Queue, gebündelte semantische Review nur explizit. API und Copy/Paste derselben Paket-/Ergebnis-Verträge. Versionen nachvollziehbar. | Periodische Batch-UI, Interaction-AI-Review, zeitliche Facet-Historie im Fachkapitel |
| Governance-Übernahme | `learning` wendet unlocked Review-Änderungen an; `advising` queued; `frozen` nicht, außer explizites Paket ohne Apply | Getrennter Bestätigungsschritt in der UI für `learning` |
| Interaction | Weiter nur explizit/manuell/angenommener Vorschlag. Dialog-Sample ist Writing-Evidence, kein Preference-Lernen. | Beobachtungs-Candidates in der Settings-UI |
Tests: `backend/tests/test_profile_review.py`.
---
# 2.4 Writing-Profile-Hülle und Initial Build (2026-08-25)
Additiv zu 2.2 und 2.3. Das statische Facet-Raster ist nicht die Profilstruktur.
| Thema | Ist-Stand | Bewusst offen |
|---|---|---|
| Hülle | Core, optionale Facets, dynamische Traits, Evidence, Exemplare, Governance, Versionen. Seed-Keys nur Ordnungshilfe. | Output-Facets jenseits Journal/Reflexion, zeitliche Trait-Historie im Fachkapitel |
| Initial Build | Historischer Korpus mit Zeitbezug; Existing-before-New; Confirm vor kontinuierlichem Lernen | Batch-Import-UI für Dateiordner, Gewichtung als Nutzersteuerung |
| Quellenarten | Urlaubstagebuch → `autobiographical_journal`; Core nur bei gemischten Arten | Weitere context/output-Facets aus dem Fachkapitel |
| Heuristik | Lokal als Drift-/Triggerhilfe, nicht als persistierte Traits | — |
Tests: `backend/tests/test_profile_governance.py`, `backend/tests/test_profile_review.py`.
---
# 3. Abgleich gegen das Gesamtziel
Quelle: `produktvision_und_produktidentitaet.md`, ergänzt um Dialog-, Memory- und Output-Kapitel. Das ist **keine** Slice-Checkliste. Ein „Gap“ hier heißt: das Zielmodell ist nicht da — erwartet, solange Foundation-Regel 23/24 gilt. Die spannenden Zeilen sind **Spannung** und **Drift-Risiko**.
## 3.1 Identität und Prinzipien
| Prinzip / Grenze | Slice-Stand | Befund für die Gesamtziel-Prüfung |
|---|---|---|
| Personal Reflection Companion, nicht Tagebuch-App | **Spannung** | Der sichtbare Kern *ist* ein dialoggeführtes Journal. Der Dialog ist Erfassung plus Impuls, nicht langfristige Begleitung. Drift-Risiko: UI und Sprache dürfen nicht als „KI-Tagebuch“ festgeschrieben werden. Der Slice bleibt zulässig, wenn Generate/Editor dem Gespräch nachgeordnet bleiben. |
| Kernkette Wahrnehmen → Reflektieren → Verstehen → Einordnen | **Teilweise** | Wahrnehmen und ein kurzes Reflektieren im Turn. Verstehen/Einordnen über Tage und Werte: nicht gebaut. Journalentwurf verdichtet den Tag, ist aber keine Erkenntnis-Schicht. |
| Dialog vor Formular | **Erfüllt** im Tag | Journal Day ist Chat. Settings-Import und Editor sind nachgelagert. Start `/` ist kein Dialog. |
| Kontext vor generischer Frage | **Teilweise** | Derselbe Day-Transcript geht in den nächsten Zug. Space ist Recency, nicht Relevanz. Kanshō eröffnet nicht. Neutraler Start ist Default, in der Vision Fallback. |
| Erinnerung mit Herkunft | **Teilweise** | Messages bleiben. Draft/Entry-Version tragen relationale Source-Refs (API weiterhin ID-Listen). Nutzer sieht „zum Quelldialog“, keine Provenance-Kette, kein Re-Grounding. |
| Hypothese statt Gewissheit | **Teilweise** | Impulse sind Nachrichten, nicht Fakten. Keine Hypothesen-Objekte, keine Confidence, keine Bestätigung vor Self-Aussagen (kein Self Model). |
| Mensch entscheidet über Identität | **Erfüllt** als Abwesenheit | Kein Self-Model-Schreibpfad. Writing Profile ist als Formhinweis gekennzeichnet. |
| Persönliche Sprache | **Teilweise** | Lokaler Brief + Exzerpte. Keine Wortwahl-/Übergangs-Signale. Eine generative Stufe, nicht Inhalt/Stil getrennt. |
| Reflection before Action | **Bewusst offen** | Keine Action Candidates, keine Kairo-Übergabe. Scratch ist lokale Merkhilfe, kein Aufgabenmanager — Grenze gehalten. |
| Keine Jinkendo-Duplikation | **Erfüllt** im Slice | Kein Tracker, keine Diagnose-UI, kein Kairo-Todo. `/dialog` ist Admin-Harness, kein zweiter produktiver Gesprächsraum. |
| Menschenlesbares Langzeitarchiv (Obsidian) | **Gap** zum Ziel | Entries liegen in SQLite. Kein Export. |
| Langfristige Kontinuität | **Teilweise** | Transcript und versionierte Entries überdauern Sitzungen. Kein Thread Memory, keine offenen Fragen, kein Point-in-Time Self. |
| Einfache UX, differenzierter Kern | **Spannung** | UX ist einfach. Der Kern ist noch nicht differenziert (ein Call, Recency, Regex-Register). Das ist Slice-angemessen; es darf nicht als „der Kern *ist* Recency“ kanonisch werden. |
| Start = Kontinuität des Dialogs, kein Dashboard | **Gap** zur Identität | `/` ist ein Stub mit Link zum Journal. `/dialog` ist nicht mehr Teil der Nutzer-IA. Primärstart ist nicht der offene Day. |
## 3.2 Dialog-IA (Ziel vs Slice)
Ziel (`dialogue_model.md`): Contextual Continuation als Default, Free Reflection, Explicit Navigation; Intent ≠ Dialogführung; Fäden intern möglich.
Slice: der Mensch schreibt die erste Zeile; Kanshō antwortet mit einem Impuls aus einem Call `{operation, impulse}`. Keine Entry-IA, keine Fortsetzung eines gestern offenen Fadens, keine Saturation, keine Wiedervorlage.
**Prüffrage:** Bleibt der Journal Day ein Gespräch über *diesen* Kalendertag? `/dialog` ist Admin-Testrest, nicht Ziel-IA.
## 3.3 Memory und Context (Ziel vs Slice)
Ziel: Working / Thread / Episodic, Space als Bedeutung, mindnet langfristig, Re-Grounding gegen Verdichtungsdrift.
Slice: Working Context ≈ aktuelle Conversation (Day-Cap) + begrenzte Recency-Ausschnitte finaler Space-Entries + begrenzte Original-Ausschnitte jüngerer Space-Conversations + Formhinweise nur beim Journalentwurf. Generate sieht **keine** Prior-Entries, nur Day-Dialog + Writing Profile. Scratch und Identity-Mapping sind Klasse A, kein Egress.
**Prüffrage:** Ist Recency als *Hilfsstruktur* erkennbar (`retrieval.py` nach `kind`), oder wird sie in Prompts/UI als „Kanshō erinnert sich“ verkauft? Der Code ist austauschbar; die Produktstimme darf das nicht überzeichnen.
## 3.4 Outputs und Integrationen (Ziel vs Slice)
Ziel: Journal Entry (Obsidian), Reflection Memory (mindnet), Knowledge Delta (mindnet), Action Candidate (Kairo). Tagesreflexion muss kein Artefakt erzeugen; Journal-Default im Ziel: bei substanzieller Reflexion dezent anbieten.
Slice: einziges sichtbares Derived-Produkt ist Journal Draft/Entry. Generate nur explizit (Button), nicht automatisch am Gesprächsende. Das weicht vom späteren Journal-Default der Tagesreflexion ab und hält Foundation „model proposes; policy decides“.
**Prüffrage:** Explizites Generate ist für den Slice richtig. Es darf nicht so verbaut werden, dass ein späteres dezenten Angebot am Abschluss den Entry still überschreibt (§11.2).
## 3.5 Privacy (Ziel vs Slice)
Ziel: Identität lokal, minimierter pseudonymisierter Egress, Detect lokal, Response-Validierung, Audit ohne Prompt, ZDR.
Slice: Gateway-Pfad existiert und ist nicht abschaltbar. Maskierung + Generate. Detect: Muster immer; externes Klartext-Detect nur Development/Test, Produktiv fällt auf Muster zurück. Minimale Response Validation vor Demask (Klartext-Identität). Keine inhaltliche Minimierung über Caps hinaus, keine Quasi-Identifikatoren, Mapping unverschlüsselt.
**Das ist der größte Abstand zum Gesamtziel, der schon im Slice operativ relevant ist** — nicht weil das Target Model fehlt, sondern weil Testdaten persönlich sein können.
## 3.6 Nutzungssituationen (Zieltypologie vs Slice)
Von elf Situationen ist nur ein Pfad gebaut: Tagesreflexion als Journal Day plus optionales Festhalten im Entry. „Erleben festhalten“ geht nur als Dialogtext, nicht als Voice. „Früheren Faden fortsetzen“, Tiefe Reflexion, Entscheidung, Revue, Nachschlagen jenseits Space→Tag→Entry: nicht als eigene IA.
Nachschlagen im Slice = Chronologie im Space. Das erfüllt MVP-Case K, nicht Natural-Language-Biografie.
## 3.7 Gesamtziel: Kurzurteil
Der Slice ist eine **zulässige vertikale Keimzelle**: Dialogquelle bleibt kanonisch, Journal ist Derived, Gateway sitzt vor Egress, Space/Day/Assignment sind trennbar, Retrieval ist austauschbar.
Er ist **noch nicht Kanshō im Sinne der Vision**. Die gefährlichen Drifts sind nicht die fehlenden Target-Module, sondern:
1. Journal-Editor und Medien als Produktmitte statt Dialog,
2. Recency und Regex als Ersatz für Context/Memory,
3. `/dialog` als Admin-Harness (geschlossen für die Nutzer-IA; Stub-Home bleibt),
4. Detect-Klartext als Gewohnheit im Produktivmodus (Development/Test bleibt erlaubt),
5. Stub-Home statt Kontinuität.
---
# 4. Abgleich Implementation Foundation
## 4.1 Core-Regeln
| # | Regel | Stand | Befund |
|---|---|---|---|
| 1 | Original Source bleibt kanonisch | **Erfüllt** | `messages` bleiben bei Generate und Entry-Save. Löschen eines Gesprächs ist ausdrücklich und hart (Nachrichten weg). |
| 2 | Derived ersetzt Source nie | **Erfüllt** | Drafts und Entries sind eigene Tabellen. Generate prüft, dass `current_version_id` unangetastet bleibt. |
| 3 | Stabile Identitäten | **Erfüllt** | UUIDs für Profile, Spaces, Days, Conversations, Messages, Drafts, Entries, Versionen, Media. |
| 4 | Zeit und Provenance | **Erfüllt** im Datenmodell | Message-`created` ≠ `journal_days.calendar_date`. Drafts/Entries tragen relationale Source-Refs. **Gap:** keine nutzbare Provenance-Ansicht jenseits „zum Quelldialog“. |
| 5 | Current Validity und History getrennt | **Erfüllt** | `journal_entry_versions`; Restore hängt eine neue Version an. Offene Editor-Edits sind kein History-Write; Verwerfen ist lokal. |
| 6 | User Correction vor Modellinferenz | **Erfüllt** für Journal | Nutzerfassung ist Current. KI-Draft ist nicht Stilquelle. **Teilweise** für Derived-Hülle: `derived_records` werden bei Entry-Korrektur nicht invalidiert (im Slice ungenutzt). |
| 7 | AI Hypothesis ist kein User Fact | **Erfüllt** im Dialogpfad | Impulse sind `assistant:`-Nachrichten, nicht Entries. Generate: Impulse sind Kontext, nicht Erlebtes. |
| 8 | Expressed state ist kein Self Model | **Erfüllt** | Kein Self-Model-Schreibpfad. Writing Profile sind Formhinweise, als „keine Diagnose“ gekennzeichnet. |
| 9 | Model proposes; policy decides; source preserves | **Erfüllt** | Generate nur explizit (`journal_policy.require_explicit_generate`). Speichern nur per Nutzer. Übernehmen aus Entwurf schreibt eine Version, überschreibt History nicht. |
| 10 | Space ist Bedeutungskontext, kein Ordner | **Teilweise** | Space ist nutzbar und Isolationseinheit; Conversation kann dem Space ohne Journal Day zugeordnet werden. Inhaltlich bleibt er eine Liste von Tagen plus Recency-Ausschnitten. Keine Space-Bedeutungsschicht. |
| 11 | Primary und Related Spaces möglich | **Teilweise** | Primary Assignment: `conversations.space_id` / `journal_day_id`. Junction `thread_spaces` existiert. **Gap:** Related Spaces weder Datenmodell für Journal-Spaces noch UI. `conversations.space_id` ohne FK. |
| 12 | Assignment ist nicht Identity | **Erfüllt** | Conversation-ID bleibt; Space/Day sind Assignment-Felder. |
| 13 | Existing before new | **Teilweise** | Denselben Kalendertag im Space wiederzuverwenden öffnet denselben Day. Speichern aus Entwurf versioniert den bestehenden Entry. Recurring Activities erzeugen keinen bestehenden Faden, sondern nichts. |
| 14 | Space first, Long-Term Memory on demand | **Erfüllt** | Kein globales autobiografisches Retrieval. Dialog sieht den Day plus letzte Space-Entries. |
| 15 | Context Builder eigene Verantwortlichkeit | **Erfüllt** | `context_builder.py` + `retrieval.py`. Frontend baut keinen Prompt. |
| 16 | Minimum sufficient context | **Teilweise** | Day-Cap und Space-Ausschnitte sind Recency-Implementierung hinter Selection-Specs. **Ungenauigkeit:** das ist keine Relevanz. |
| 17 | Retrieval ist Hilfsstruktur | **Erfüllt** | SQL-Funktionen nach `kind`, keine Embeddings als Wahrheit. |
| 18 | Provider State ist nicht Memory | **Erfüllt** | Kontinuität in SQLite. Provider sieht maskierten Prompt. |
| 19 | Ein generativer Hauptcall pro Turn | **Erfüllt** | Ein Dialogzug-Call; optional ein lokaler Repair-Call. Detect ist Maskierung, nicht Operation. Journalentwurf getrennt und explizit. |
| 20 | Write Evaluation signal-driven | **Erfüllt** | Kein exhaustives Memory-/Pattern-Write nach jeder Message. `remember_dialogue_style` ist lokal und schmal. |
| 21 | Variable Policies nicht quer hardcoden | **Teilweise** | `journal_policy.py` ist die Journal-Grenze inkl. Konsolidierungsangebot aus lokalen Signalen. Register, Guards, UNMASKABLE liegen als Heuristiken im Code. Für den MVP laut Foundation keine Policy-Engine nötig. **Ungenauigkeit:** „Policy“ im Dialog ist Prompt + Regex, keine zentrale Decision-Tabelle. |
| 22 | Interne Reichhaltigkeit, einfache UI | **Teilweise** | Journal-UI ist schmal. `/dialog` ist Admin-Harness, nicht Produkt-Nav. Admin-Bereich (Prompts, Provider, Nutzer, Diagnose) bleibt Mitai-Rahmen. |
| 23 | Neue Abstraktionen brauchen Nutzen | **Erfüllt** | Threads/Derived/Handoffs sind Hüllen, ohne Nutzer-IA. |
| 24 | Nicht implementiert ist zulässig | **Erfüllt** | Space Memory, Related Spaces, semantisches Retrieval bewusst offen. |
## 4.2 Foundation Cut: muss vorhanden sein
| Muss | Stand | Befund |
|---|---|---|
| Persistente Session- und Message-Quellen | **Erfüllt** | `usage_sessions`, `conversations`, `messages`; Auth-`sessions` getrennt |
| Stabile IDs | **Erfüllt** | |
| Zeitbezug und grundlegende Provenance | **Erfüllt** | siehe Regel 4 |
| Trennung Source / Derived | **Erfüllt** | |
| Reflection-Space-Grundmodell | **Erfüllt** | `spaces.visibility='user'` |
| Primary-Space-Zuordnung | **Erfüllt** | Assignment-Felder; Journal-Day erzwingt Space |
| Datenmodell darf Related später zulassen | **Teilweise** | `thread_spaces` ja; Journal-Space-Related fehlt |
| Current-State-/History für verwendete Objekte | **Erfüllt** | Entries + Versionen; Dirty-State ist Session-UI, nicht History |
| Nutzerkorrektur beeinflusst Current Validity | **Erfüllt** | |
| Context-Builder-Schnittstelle | **Erfüllt** | |
| Austauschbare Retrieval-Schnittstelle | **Erfüllt** | |
| Provider-Abstraktion | **Erfüllt** | `providers.py`, Rollen generate/detect |
| Kleine Write-/Decision-Policy-Grenze | **Erfüllt** | `journal_policy.py` |
## 4.3 Foundation: nur bauen wenn der Slice es braucht
| Gegenstand | Im Code | Bewertung |
|---|---|---|
| Thread | Tabelle + Junction, visibility internal | Hülle, keine UI. **Kein Gap**, solange nicht als Produkt verkauft. |
| Open Question / Insight / Experience | Derived-Kind-Registry, keine Nutzung | **Bewusst offen** |
| Sichtbare Related Spaces | nein | **Bewusst offen** |
| Automatische Space-Erkennung | nein | **Bewusst offen** |
| Semantisches Retrieval | nein | **Bewusst offen** |
| Session Summary | nein | **Bewusst offen** |
| Zusätzliche Enrichment-Calls | Detect in Development/Test optional remote | **Abweichung** gegenüber Produktivziel „Detect lokal“; Produktivmodus lässt externes Klartext-Detect nicht als normalen Pfad zu |
## 4.4 Foundation Non-Goals
Nicht vorsorglich gebaut: Reflection Graph, Graph-DB, Pattern-Engine, Self Model, Landmark-Engine, Policy-Learning, Space-Merges, mindnet-Sync, Resurfacing, Persönlichkeitsanalyse, Intent-Engines, Graph-UI, Dashboards.
**Spannung, kein Slice-Gap:** Es gibt eine Admin-Oberfläche (Nutzer, Prompts, Platzhalter, Features, Provider, Dialogdiagnose). Das MVP-Non-Goal „komplexe Admin-/Governance-Oberfläche“ meint die Produkt-IA. Der Mitai-Produktrahmen sieht Operator-Admin vor. Die Testspur am Dialog ist bewusst Testphase, nicht Default-UX.
Foundation-Verletzungen, die ein Refactor erzwingen würden, sind **nicht** gefunden: Source/Derived, IDs, Space-Assignment, Context Builder, Gateway vor Egress, explizites Generate, Versionierung ohne History-Overwrite.
---
# 5. Abgleich MVP-Spezifikation
## 5.1 Produktversprechen (`mvp.md` §12)
| Anforderung | Stand | Befund |
|---|---|---|
| Dialoggeführtes Journal, Entwurf aus dem Gespräch | **Erfüllt** | Explizites Generate, Öffnen als Entwurf |
| Redigieren, Medien, chronologisch wiederfinden | **Teilweise** | Editor mit Absätzen, Listen, Inline-Bild/Video und Unterschrift. Navigation über Tage, nicht eine Entry-Liste |
| Dialog berücksichtigt Tagesverlauf, Space, frühere Aussagen, Erzählweise | **Teilweise** | Tagesverlauf: voller Day-Dialog. Erzählweise: Formhinweise. Space/Früheres: Recency-Ausschnitte gespeicherter Entries, 5×400 Zeichen. **Ungenauigkeit** von §1 gegenüber der Umsetzung in §5.1 letzter Absatz und §8.2 |
## 5.2 Sichtbare Einheiten
| Einheit | Stand | Befund |
|---|---|---|
| Space anlegen, umbenennen, auswählen | **Erfüllt** | |
| Chronologische Entries des Spaces | **Teilweise** | Space-Seite zeigt **Tage**. Entries liegen unter dem Tag. |
| Journal Day ≠ Dialogzeit | **Erfüllt** | |
| Originaldialog erhalten | **Erfüllt** | |
| Gespräch aus dem Tag entfernen | **Erfüllt** | Hartes Löschen der Conversation. **Ungenauigkeit:** härter als Entry-Soft-Delete; kein Papierkorb für Dialoge |
| Draft Derived, erneut generierbar, überschreibt Entry nicht | **Erfüllt** | UI öffnet den neuen Entwurf; Übernehmen ist explizit und versioniert den bestehenden Entry. |
| Entry mit Versionen, Space, Day, Quellen | **Erfüllt** | Medien am Entry; Position und Unterschrift im Body-Token |
## 5.3 Mehrere Dialoge (`mvp.md` §4)
| Anforderung | Stand | Befund |
|---|---|---|
| Mehrere Conversations pro Day | **Erfüllt** | |
| Konsolidierung anbieten, nicht erzwingen | **Erfüllt** im Slice-Schnitt | Angebot nur bei hinreichend ähnlichen lokalen Signalen (`narrative_mode`, `reflection_depth`, `current_focus`). UI: dieses Gespräch / alle des Tages. Nutzer entscheidet über `conversation_ids`. |
| Getrennte Tiefen nicht automatisch mergen | **Erfüllt** | Ohne Auswahl nur die letzte Conversation |
| Gleicher Kalendertag, verschiedene Spaces getrennt | **Erfüllt** | Unique `(space_id, calendar_date)` |
## 5.4 Dialogführung (`mvp.md` §57)
| Anforderung | Stand | Befund |
|---|---|---|
| Kein Fragebogen; Reflexionspartner | **Teilweise** | Prompt und Stimme zielen darauf. Qualität ist modellabhängig. Lokale Wächter gegen Recap, erfundenen Vollzug, erfundene Empfindung, Systemfloskeln, Schluss-Wiederöffnung. `local_hold` bittet um Fortsetzung statt Floskel. |
| Sechs Operationen, keine Intent-Engines | **Erfüllt** | Ein Call, JSON `{operation, impulse}`; sichtbar ist `impulse` |
| Register lokal, kein zweites Modell | **Erfüllt** | `infer_register` |
| Bekannter Tagesplan (Cres-Beispiel) | **Teilweise / Ungenauigkeit** | Plan kann aus begrenzten Recency-Ausschnitten **gespeicherter Entries** oder **jüngerer Original-Conversations** desselben Space kommen. Kein Plan-Objekt, keine zeitliche Vor/Nach-Logik, kein semantisches Retrieval. |
| Wiederkehrende Aktivität (Japanisch-Beispiel) | **Gap** | Keine Zählung über Morgenbeschreibungen. Recency kann den Treffer zufällig enthalten oder nicht. |
| Keine triviale Gefühlsfrage nach selbst benannter Emotion | **Teilweise** | Prompt + `is_unearned_stance`. Kein Dialogzustand „bereits beantwortet“. |
| Kontextsensitiver Einstieg statt „Erzähl von deinem Tag“ | **Gap** | Nutzer schreibt die erste Zeile. Kanshō eröffnet nicht aus Plan/Space. Neutraler Start ist der Default, nicht der Fallback. |
| Unterbrechen und Fortsetzen am selben Day | **Erfüllt** | Transcript bleibt. **Teilweise:** keine lokale Karte „welche Tagesabschnitte schon da sind“. |
| Folgetag-Fortsetzung dem ursprünglichen Day zuordnen | **Erfüllt** | Nutzer öffnet den Day; Message-Zeit ist unabhängig. |
## 5.5 Generierung (`mvp.md` §8)
| Anforderung | Stand | Befund |
|---|---|---|
| Nur explizit | **Erfüllt** | |
| Quellen: Day-Dialog inkl. Impulse als Kontext | **Erfüllt** | `user:` Erlebtes, `assistant:` Anschluss |
| Space-Kontext soweit nötig | **Teilweise** | Generate bekommt **keinen** Prior-Entry-Block; nur Writing Profile + Day-Dialog. Dialogzug sieht Prior-Entries. |
| Writing Profile | **Erfüllt** | Lokal, ohne Extra-LLM |
| Bestehenden Text einbeziehen | **Teilweise** | API `include_existing`. **Gap:** UI setzt das Flag nicht. |
| Verbinden, nicht durchreichen | **Teilweise** | Prompt: Szenen verbinden und Stimme aus dem Writing Profile; kurze Passagen ausformulieren ohne neue Fakten. `journal_shape` formt 1:1-Kopien und dünnt Namenswiederholungen. Qualität modellabhängig. Zweistufige Generierung (Inhalt, dann Stil) aus dem Fachkapitel: **nicht** gebaut, ein Call. |
| Nicht erfinden | **Teilweise** | Prompt; keine lokale Faktenprüfung gegen den Dialog |
## 5.6 Writing Profile (`mvp.md` §9)
| Anforderung | Stand | Befund |
|---|---|---|
| Priorität Entries > Import > Dialog | **Erfüllt** | Finale Entries: mehr/längere Exzerpte; Dialog-Exzerpte entfallen, sobald Entries existieren. |
| KI-Draft keine Stilquelle | **Erfüllt** | |
| Import optional | **Erfüllt** | Settings |
| Formhinweise ohne Self Model | **Erfüllt** | Heuristik |
| Semantische Review | **Erfüllt** als expliziter Pfad | Gebündelt, API oder Copy/Paste, nicht nach jedem Dialog. Siehe 2.3. |
| Profilhülle / dynamische Traits | **Erfüllt** als Keim | Core + Facets + datengetriebene Traits. Kein festes Raster. Siehe 2.4. |
| Initial Profile Build | **Erfüllt** als expliziter Pfad | Historischer Korpus, Confirm vor Continuous Learning. |
| Lernsignale §9.3 vollständig | **Teilweise** | Satzlänge, Chronologie vs. Thema, Uhrzeiten, Reflexionsanteil, Humor. **Gap:** Umgang mit Namen, typische Übergänge, Wortwahl als eigene Signale. |
| Dialogzug ohne Stil-Exzerpte des offenen Turns | **Erfüllt** | |
## 5.7 Editor, Versionen, Nachschlagen, Screens
| Anforderung | Stand | Befund |
|---|---|---|
| Text ändern, Absätze, Überschrift | **Erfüllt** | Fließtext; Absatz/Überschrift/Liste; Fett/Kursiv/Unterstrichen auf der Markierung |
| Bilder ergänzen, anordnen | **Erfüllt** | Fließtext, Einfügen an der Schreibmarke, Ziehen im Text, Unterschrift. Alte Galerie-Dateien ohne Referenz: „Noch nicht im Text“. |
| Videos im Eintrag darstellen | **Erfüllt** | MP4/WebM/MOV, lokal, direkt abspielen. Keine Videobibliothek. Metadaten in Video-Containern werden **nicht** gestrippt (**Gap** / bewusst dünn). |
| Zeichnungen als Bilddatei | **Erfüllt** | als Upload |
| Soft Delete / Papierkorb | **Teilweise** | `deleted_at` + DELETE-API. Editor-Text: „In den Papierkorb“. **Gap:** keine Papierkorb-UI, kein Wiederherstellen Gelöschter, kein zweiter Schritt „endgültig löschen“. |
| Version History sichtbar, Restore | **Erfüllt** | Aktuelle Fassung ohne Restore auf sich selbst; offene Edits lokal verwerfen. Navigation mit Dirty-Flag bestätigt (Links, Abmelden, `beforeunload`). Browser-Zurück nicht hart blockiert. |
| Generate überschreibt Nutzerfassung nicht | **Erfüllt** | |
| Chronologische Liste, Space, Datum, Entry, Quelldialog | **Teilweise** | Über Space → Tage → Tag/Editor/Source. Keine Entry-Indexseite. |
| Screen Journal/Spaces | **Erfüllt** | |
| Screen Journal Day / Dialog | **Teilweise** | Chat + Generate + lokale Stichpunkte (Return fokussiert neue Zeile). **Gap:** keine Medienoption am Dialog (MVP §13.2 Dialogquellen). Draft/Entry nur Links. |
| Screen Editor | **Erfüllt** | Fließtexteditor, Markdown, Inline-Medien, Restore, Dirty-Schutz |
| Screen Source Dialog | **Erfüllt** | |
| Start = Kontinuität des Dialogs | **Gap** zur Produktidentität | `/` ist ein Stub. Primärstart ist nicht der offene Day. `/dialog` ist Admin-Harness, nicht Nutzer-IA. |
| Tagesstichpunkte §10.1 | **Erfüllt** als lokale Liste | Klasse A, kein Egress (Test). Kein Aufgabenmanager. Offen bleibt eine spätere bewusste, maskierte Nutzung als Kontext. |
## 5.8 Acceptance Cases
| Case | Stand | Was der Code prüft vs. was die Spezifikation meint |
|---|---|---|
| **A** Einfacher Day | **Erfüllt** | Dialog, Generate, Save, Space-Chronologie. Lesbarkeit/Quelltreue des Entwurfs ist modellabhängig, Shape nur bei 1:1-Paste. |
| **B** Mehrphasiger Tag | **Erfüllt** | Gleicher Day, spätere Messages, Kalenderdatum stabil. |
| **C** Kontextsensitiver Dialog | **Teilweise / Ungenauigkeit** | Test prüft, dass „Hafen“ im Prompt steht und die Regel „nicht geschehen“ im Template ist. Er prüft nicht, dass Kanshō den Plan als Impuls aufgreift. Wiederkehrende Aktivität: **Gap**. |
| **D** Ausführliche Erzählung | **Teilweise** | Ein Call, dichtes Register. Ob der Impuls nicht interviewt, ist modell- + wächterabhängig. Tests erzwingen keine inhaltsleere Fragefreiheit. |
| **E** Emotionaler Abschnitt | **Teilweise** | Prompt + Stance-Wächter. Test sucht die Phrase „Motivationen erfinden“ im Prompt, nicht das Dialogverhalten. |
| **F** Unterschiedliche Dialogtypen | **Erfüllt** im Schnitt | Zwei Conversations möglich, kein Zwangsmerge. Lokale Signale trennen Chronik und tiefe Reflexion; Angebot bleibt aus. |
| **G** Ähnliche Dialoge, Angebot | **Erfüllt** im Schnitt | Angebot nur bei ähnlichen Signalen. Nutzer entscheidet über `conversation_ids`. |
| **H** Zwei Spaces, gleicher Tag | **Erfüllt** | |
| **I** Persönlicher Stil | **Teilweise** | Import und Entries im Brief; Formhinweise. „Nicht in KI-Stil driftet“ ist Promptziel, nicht messbar erfüllt. |
| **J** Versionsschutz | **Erfüllt** | Restore hängt Version an; Generate ändert Entry nicht; Entwurf öffnet separat; Übernehmen versioniert. |
| **K** Nachschlagen | **Erfüllt** | Space → Datum → Entry → Source. |
---
# 6. Ungenauigkeiten (Doku, Tests, UI)
1. **`mvp.md` §1 und §5.1** versprechen „relevante frühere Aussagen“ und „wiederkehrende Aktivitäten“. Der Code liefert Recency-Ausschnitte finaler Entries plus begrenzte Original-Ausschnitte jüngerer Space-Conversations. Der klarstellende Absatz in §5.1 und §8.2 ist der gültige Implementierungsstand; die Beispiele §5.45.5 und Case C bleiben **schärfer als der Code** (kein Plan-Objekt, keine Wiederholungszählung).
2. **`mvp.md` §17** sprach vom „nächsten Schritt: technischen Plan ableiten“. Der Slice ist gebaut. Dieses Dokument ist der Abgleich; die Formulierung in §17 bleibt historisch.
3. **`mvp.md` §4.1 Konsolidierung** beschreibt narrative Kriterien. Der Slice nutzt lokale Signale derselben Dimensionen, keine LLM-Typklassifikation.
4. **`mvp.md` §6 Einstieg** verlangt einen kontextsensitiven ersten Impuls. Implementiert ist: der Mensch beginnt.
5. **`mvp.md` §8.2 Punkt 2** Space-Kontext bei der Generierung: Generate sieht Prior-Entries nicht.
6. **`mvp.md` §9.3** listet Lernsignale, von denen nur ein Teil heuristisch existiert.
7. **`mvp.md` §11.3 / §13.113.2** Papierkorb-UI und Dialog-Medien fehlen weiter. Inline-Medien, Unterschriften, Versions-UI und Dirty-Schutz im Editor sind vorhanden.
8. **Acceptance-Tests C/D/E** sind oft Prompt- oder Call-Zähl-Proxies, keine fachlichen Verhaltensbeweise.
9. **`backend_and_api.md`** beschreibt PostgreSQL als Rahmen; der Slice läuft auf **SQLite**. **Abweichung** vom technischen Ziel, lokal zulässig.
10. **Fehlerformat:** Rahmen will `{detail: string}`. Viele Router liefern `{detail: {code, message}}`.
11. **Nav `/dialog`** ist aus der Produkt-IA entfernt (Admin-Harness). Stub-Home bleibt.
12. **Detect über OpenRouter** darf Klartext nur in Development/Test sehen. Produktivmodus fällt auf Muster oder lokale Detect-URL zurück. Guardrail-Abstand zum lokalen Detect-Modell bleibt.
13. **Home `/`** widerspricht der Produktidentität „Kontinuität des Dialogs, kein Funktions-Dashboard“ nur schwach (Stub), erfüllt sie aber nicht.
14. **`handover.md`** ist Konzept-Bootstrap (Interviewstand 2026-08-19) und **kein** Code-Stand. Code-Stand ist dieses Dokument.
15. **Zweistufige Journalgenerierung** im Fachkapitel Writing Profile ist Zielmodell. Der Slice hat einen Generate-Call. `mvp.md` verlangt das Zweistufenmodell nicht zwingend; das Fachkapitel schon. Nicht als Slice-Bug lesen, nicht als erfüllt behaupten.
16. **Tagesreflexion-Journal-Default** (Hintergrund-Entwurf am Abschluss, dezent anbieten) ist Fachstand der Nutzungssituation, nicht MVP-Text. Der Slice verlangt den Button. Kein Widerspruch, solange später kein stilles Überschreiben entsteht.
---
# 7. Bewusst offen (kein Gap des Slices)
Zielmodell und Non-Goals, nicht „vergessen im MVP“:
- Space Content Memory, Threads-UI, Open Questions, Insights, Experiences
- Semantisches Retrieval, Session-Summaries, Related Spaces
- Self Model, Pattern-Ansicht, Resurfacing, mindnet, Obsidian-Ablage, Kairo-Handoff
- Voice, Videobibliothek, Dokumentarchive, Karten
- Video-Metadaten (GPS im Container) strippen
- Tagesstichpunkte als Dialogkontext (bewusst lokal, siehe `mvp.md` §10.1)
- Encryption at rest, DSFA, Multi-Gerät-Sync, PWA-Offline-Ausprägung
- Lokales Detect-Modell (Ollama) als Ablösung der Testphase
- `include_existing` in der UI (API existiert)
- Zweistufige Journalgenerierung (Inhalt, dann Stil)
- Contextual Continuation als Start-IA
- Browser-Zurück als harter Unsaved-Blocker (Data-Router)
---
# 8. Zwei Listen: Slice schließen vs. Gesamtziel später
Keine neue Horizontal-Konzeption. Getrennt halten, sonst baut ein Agent das Target Model in den Slice.
## 8.1 Distanz zum **freigegebenen MVP-Text** (`mvp.md`)
1. **Kontextsensitiver Einstieg und Case C** Plan/Wiederholung nur, wenn sie wirklich im Space-Kontext liegen; sonst ehrlich kein Cres-Beispiel, oder §5.45.5 an Recency anpassen.
2. **Generate und Space-Ausschnitte** klären, ob Generate Prior-Entries braucht oder §8.2 korrigiert bleibt (Day-Dialog reicht).
3. **Editor-Lücken** Papierkorb oder zweiter Löschschritt; Dialog-Medien oder Streichung in §13.2.
4. **Startlogik** offenen Journal Day statt Stub-Home.
5. **Lokales Detect-Modell** Klartext an externe Detect-Provider ist in Production blockiert; Ollama als Ablösung der Dev/Test-Phase bleibt offen.
6. **`include_existing`** UI oder Flag aus der Spezifikation nehmen.
## 8.2 Distanz zum **Gesamtziel** (nicht jetzt bauen)
1. Memory-Schichten und Space als Bedeutung, nicht Recency-Liste.
2. Contextual Continuation, Saturation, Wiedervorlage, Fäden.
3. Outputs jenseits Journal (Memory, Knowledge, Action) und Integrationen.
4. Self Model mit Bestätigung; Point-in-Time Self.
5. Re-Grounding und Provenance-UI.
6. Privacy Security Layer (Minimierung, lokales Detect, Response-Validierung, Audit, Encryption).
7. Voice, Offline, Obsidian.
Ein Agent, der 8.2 in diesem Slice „schließt“, verletzt Foundation-Regeln 2224.
---
# 9. Dateikarte für die Gegenprüfung
## 9.1 Fachlich führend
| Datei | Rolle in der Prüfung |
|---|---|
| `produktvision_und_produktidentitaet.md` | Gesamtziel, Prinzipien, Nicht-Ziele |
| `mvp.md` | Freigegebener Slice |
| `implementation_foundation.md` | Sackgassen-Verbot |
| dieses Dokument | Ist-Stand |
| `guardrails.md` | Datenschutz-Invariante |
| `writing_profile_and_journaling.md` | Stilziel; Zweistufenmodell ist Ziel, nicht Slice |
## 9.2 Code, der den Slice trägt
| Pfad | Verantwortung |
|---|---|
| `backend/privacy_gateway.py`, `entity_detect.py`, `pronoun_bind.py`, `identity_store.py` | Egress, Maskierung, Dialog-Pronomen |
| `backend/context_builder.py`, `retrieval.py`, `conversation_signals.py` | Interner Kontext, Selection-Specs, lokale Dialogsignale |
| `backend/dialogue_turn.py` | Ein Call, Operation/Impuls, lokale Wächter; kein Writing-Profile-Kernel |
| `backend/journal_generate.py`, `journal_shape.py`, `journal_body.py` | Entwurf, Shape, Titel/Markdown |
| `backend/journal_store.py`, `journal_policy.py` | Entries, Versionen, Scratch, explizites Generate |
| `backend/writing_profile_store.py` | Lokaler Brief |
| `backend/media_store.py` | Lokale Medien, Token im Body |
| `frontend/src/pages/JournalDayPage.jsx` | Dialog + Generate + Scratch |
| `frontend/src/pages/JournalEditorPage.jsx` | Editor, Versionen, Dirty |
| `frontend/src/context/UnsavedChanges.jsx` | Navigationsschutz |
| `frontend/src/components/DayScratch.jsx` | Lokale Checkliste |
| `frontend/src/journal/document.js` | Markdown ↔ TipTap |
## 9.3 Tests und ihre Grenze
| Suite | Was sie trägt | Was sie nicht trägt |
|---|---|---|
| `backend/tests/test_mvp_journal.py` | Day, Generate ändert Entry nicht, Scratch nicht im Egress, Spaces isoliert, Konsolidierung nach Signalen | Dialogqualität, Stil, Case C Verhalten |
| `backend/tests/test_architecture_correction.py` | Provenance-Migration, Space ohne Day, Space-Source-Plan, Detect-Env, Kernel-Grenzen | — |
| `backend/tests/test_privacy_detect.py` | Maskierung, Pronomen Dialog vs Journal, Response Validation, Production-Detect | Quasi-Identifikatoren, Provider-ZDR |
| `backend/tests/test_journal_shape.py` | Namensausdünnung | Quelltreue des Modells |
| `backend/tests/test_writing_profile.py` | Quellenpriorität | „klingt wie die Person“ |
| `backend/tests/test_frame.py` | Auth, Prompts, Rahmen | Journal-IA |
| `frontend/src/journal/document.test.js` | Markdown-Runde | Editor-UX, Dirty-Flag |
Laufzeit lokal: Frontend Port **5188**, Backend **8018**, SQLite `backend/data/kansho.sqlite`. Prompt-Templates in der DB werden bei unangetasteten Seeds durch `init_db` aktualisiert; nach Prompt-Änderungen Backend neu starten.
---
# 10. Empfohlene Prüffragen (für den zweiten Agenten)
Nicht als Implementierungsbacklog, sondern als Gegenlesung:
1. Wird im Code oder in der UI etwas als Erinnerung, Verständnis oder Identität verkauft, das nur Recency, Regex oder ein Writing-Brief ist?
2. Bleibt der Dialog die Primärquelle, oder zieht der Editor (Medien, Versionen) die Produktmitte auf das Journal?
3. Ist das Gateway vor jedem persönlichen Generate-Call, und ist Detect-Klartext als Testphase kenntlich — in Admin-UI und Doku?
4. Kann ein späteres Thread Memory / Related Space / Re-Grounding an IDs, Assignment und Retrieval andocken, ohne Messages oder Entries umzuschlüsseln?
5. Welche der Lücken in Abschnitt 8.1 sind echte Spec-Schulden, welche sollten in `mvp.md` auf den Code zurückgeschrieben werden, damit Vision und Slice nicht weiter auseinanderlaufen?
6. Drift-Risiko Stub-Home: bleibt die Startlogik gegen „Kontinuität des Dialogs“ dünn, obwohl `/dialog` nicht mehr in der Nutzer-IA liegt?
7. Scratch: bleibt die Grenze „kein Aufgabenmanager, kein Egress“ in Copy und Tests klar?
Wenn diese sieben Punkte tragfähig beantwortet sind, ist der Slice gegen das Gesamtziel **als Keimzelle** prüfbar — ohne das Target Model einzufordern.

View File

@ -47,6 +47,14 @@ Echte Mapping-Tabelle nur soweit eine besonders berechtigte Rolle es braucht (`g
Nicht übernehmen: Mitai-Admin für Körpertarife, Coupons, Training Types als Kanshō-Kern. Feature-Admin nur als Muster, wenn Entitlements genutzt werden.
## 4.1 Implementierungsstand (Schnittstellen)
**Status: Code vorhanden.** Admin-Seite `/admin/providers` und `GET/PUT /api/admin/providers`. Zeigt URL, Modell, ZDR, No-Train, `ready` und `key_present` niemals den Secret. Key-Schreiben aktualisiert nur `backend/.env`. Generate bleibt fail-closed ohne Key/ZDR/No-Train. Kein Gateway-Bypass.
## 4.2 Implementierungsstand (Dialog-Testspur)
**Status: Code vorhanden, Testphase.** Nach jedem Dialogzug und nach Generate erhält die Admin-Rolle `decision` und `trace` in der API-Antwort. Die Journal- und Dialogseite zeigen Zweck, Prompt, Provider/Modell, Detect, maskierten Egress und Antworten. Klasse-A-Mapping bleibt unsichtbar. Ohne Admin-Rolle fehlen diese Felder. Nicht persistiert, nicht für den Regelbetrieb gedacht.
## 5. Entscheidungsstand
| Thema | Stand | Status |

View File

@ -83,6 +83,32 @@ Konkreter Pfad: offen, Muster übernommen.
| OpenAPI als dokumentierter Vertrag | später | offen |
| Interne service-Schicht vs. fette Router | service-Schicht | bevorzugte Richtung |
## 5.1 Implementierungsstand (MVP-Journal-API)
**Status: Code vorhanden.** Router: `backend/routers/journal.py`, Prefix `/api/journal`. Zusammenhängende Umsetzung: `mvp_implementation.md`.
Produkt-Endpunkte hinter Session-Auth, Isolation über `profile_id`:
- Spaces: `GET/POST /spaces`, `GET/PATCH /spaces/{id}`, `POST /spaces/{id}/days`
- Days: `GET /days/{id}` inkl. Conversations, Current Draft, Entries, `consolidation_offer`
- Dialog: `POST /days/{id}/conversations`, `POST /conversations/{id}/turn` (ein Modell-Call, bei Erzähler-Fortsetzung einmal Korrektur), `GET /conversations/{id}`, `DELETE /conversations/{id}` (Profil-isoliert; Nachrichten und Derived dieser Conversation gehen mit)
- Status: `GET /egress-status` (Provider bereit / fail-closed)
- Derselbe Zug auch unter `POST /api/dialogue/conversations/{id}/turn`
- Admin-Antwort zusätzlich `decision` und `trace` (Testphase)
- Generate: `POST /days/{id}/generate` nur explizit; optionale `conversation_ids`; ohne Auswahl kein stilles Mergen
- Entries: `POST /entries`, `GET /entries/{id}`, Versionen, Restore, Soft-Delete. Speichern aus einem Entwurf mit `entry_id` + `origin=accepted_draft` legt eine neue Version desselben Entry an.
- Media: Upload/GET/DELETE; Bilder und einzelne Videos; Position/Unterschrift im Entry-Body als Markdown `![…](kansho-media:<id>)`
- Tagesstichpunkte: `PATCH /days/{id}/scratch` — lokal, nicht Context-Builder
- Writing Profile: `GET /writing-profile`, Import (optional `occurred_at`, `context_hint`), Korpus `GET/POST /writing-profile/corpus`, Rebuild, `PATCH` Governance, Facet-Edit/Lock, Trait-Edit `PATCH /writing-profile/traits/{slug}`, Vorschläge annehmen/verwerfen. Brief ist eine abgeleitete Sicht aus Traits und zeitgestempelten Quellen. Media-Token werden vor dem Brief entfernt. JSON-Export/Restore: `GET /writing-profile/export`, `POST /writing-profile/restore`. Review: `GET /writing-profile/review`, `POST /writing-profile/review/paste`, `POST /writing-profile/review/api`, `POST /writing-profile/review/import`. Initial Build: `POST /writing-profile/initial-build/paste`, `POST /writing-profile/initial-build/api`. API und Copy/Paste nutzen denselben Vertrag `kansho.profile_review_package` / `kansho.profile_review_result` (`mode`, `corpus`, `existing_before_new`).
- Interaction Profile: `GET /interaction-profile`, `PATCH` Governance und Präferenzen, Vorschläge annehmen/verwerfen. Keine Ableitung aus Nicht-Widersprechen. Der Slot liegt unter `/api/journal` nur als Settings-Nachbar, nicht als Journal-Artefakt. JSON-Export/Restore: `GET /interaction-profile/export`, `POST /interaction-profile/restore`.
- Beide Profile: `GET /profiles/export`, `POST /profiles/restore` (`kind: kansho.profiles`). Kein Journal-Backup.
`/api/dialogue/*` bleibt Low-Level-Source und Admin-Diagnose, nicht die Produkt-IA. Context Builder und Retrieval liegen nicht im Frontend.
## 5.2 Implementierungsstand (Provider-Admin)
**Status: Code vorhanden.** `GET /api/admin/providers`, `PUT /api/admin/providers/{generate|detect}`. Session-Admin. Body darf einen write-only `key` enthalten; die Antwort enthält ihn nicht. Persistenz: URL/Modell/Flags in `provider_settings`, Key in `backend/.env`.
## 6. Offene Fragen
1. Internes API für mindnet/Kairo: synchron REST vs. Events? → `integrations_technical.md`

View File

@ -11,6 +11,8 @@ parent_document: "technische_zielarchitektur.md"
Für technische Arbeit nur die benötigten Dateien laden. Fachliche Homes nicht durch technische Kurzfassungen ersetzen. Dialog-/MVP-Stand nicht als final behandeln. **Datenschutz und Guardrails** (`../functional/guardrails.md`) sind verbindliche Invariante. Mitai-Produktrahmen weitgehend übernehmen, Gateway nicht umgehen.
**Technische Umsetzung (kanonisches Home):** `mvp_implementation.md`. **Fit-Gap-Analyse (fachlich):** `../functional/mvp_stand_und_abgleich.md` (2026-08-25). Technische Kapitel enthalten lokale Implementierungsstände; sie ersetzen weder die Umsetzungsbeschreibung noch die Fit-Gap-Analyse.
## 1. Root-Dokumente
Technisch:
@ -41,6 +43,7 @@ Fachlich zusätzlich immer bei Querschnittsentscheidungen:
| `admin_diagnostics.md` | Diagnoseansicht |
| `voice_and_media.md` | Sprache und Transkription |
| `security.md` | Security-Baseline |
| `mvp_implementation.md` | **Kanonisches Home der technischen MVP-Umsetzung** (Laufzeit, Module, Daten, API, Pfade). Fit-Gap bleibt fachlich. |
## 3. Empfohlene Context Bundles
@ -97,6 +100,21 @@ Fachlich zusätzlich immer bei Querschnittsentscheidungen:
3. fachlich: `../functional/fachliche_zielarchitektur.md` §2.7
4. `../functional/reflection_spaces.md` (Admin-/Developer View)
### MVP-Journal-Slice / technische Umsetzung
Kanonisches Home **wie** der Slice gebaut ist: `mvp_implementation.md`. Fachliches Fit-Gap-Home bleibt `../functional/mvp_stand_und_abgleich.md`.
1. `mvp_implementation.md`
2. `memory_storage_and_offline.md` §6.2
3. `backend_and_api.md` §5.1
4. `privacy_gateway.md` §9.19.2
5. `frontend_pwa_shell.md` §6.1
6. fachlich: `../functional/mvp.md`
7. `../functional/implementation_foundation.md`
8. `../functional/mvp_stand_und_abgleich.md`
9. bei Gegenlesung gegen das Gesamtziel: `../functional/produktvision_und_produktidentitaet.md`
10. bei Bedarf: `../functional/writing_profile_and_journaling.md`, `../functional/guardrails.md`
### Voice / PWA-Offline
1. `frontend_pwa_shell.md`
@ -113,5 +131,5 @@ Vor Vereinfachungen die Invariantenliste in `technische_zielarchitektur.md` §3.
## 5. Code-Gerüst
Lokaler Produktrahmen in `frontend/` und `backend/`. Auth, Nutzerverwaltung, Prompt-DB, Platzhalter, Feature-Check, Privacy-Gateway-Stub. **Dialog-Layer 0:** Conversations/Messages/`usage_sessions`, Thread-/Space-Identität, Derived-Hülle mit Provenance. Keine Dialog-IA, keine Domain-Prompts, kein Stripe. SQLite nur lokal.
Lokaler Produktrahmen in `frontend/` und `backend/`. Zusammenhängende technische Umsetzung: `mvp_implementation.md`. Auth, Nutzerverwaltung, Prompt-DB, Platzhalter, Feature-Check, Privacy Gateway. **Dialog-Layer 0:** Conversations/Messages/`usage_sessions`, Thread-/Space-Identität, Derived-Hülle mit Provenance. **MVP-Journal-Slice:** nutzersichtbare Spaces, Journal Days, Dialogzug (ein Call), explizite Draft-Generierung, versionierte Entries (Übernehmen = neue Version, Dirty-Schutz im Editor), Inline-Medien, lokale Stichpunkte ohne Egress, Writing Profile (Hülle mit dynamischen Traits, Initial Profile Build). Fit-Gap: `../functional/mvp_stand_und_abgleich.md` (2026-08-25). Konfigurierbare Prompts `mvp.dialogue_turn`, `mvp.journal_generate`, `mvp.entity_detect`, `mvp.profile_review`. Provider-URL/Modell/Policy unter Admin → Schnittstellen; Keys nur in `backend/.env`. Schichten: Maskierung, Dialogzug, Journalentwurf, explizite Profile-Review. Admin-Testspur am Dialogzug (Egress, Antwort, Operation). Privacy-Pfad durchgängig, Security Layer nicht vollständig (`privacy_gateway.md` §9.2). Kein Stripe, keine Threads-UI, kein semantisches Retrieval. SQLite nur lokal.

View File

@ -74,6 +74,26 @@ Business-Logik bleibt im Backend (`backend_and_api.md`).
| Kanshō-Startroute / endgültige IA | folgt späterem Fachstand | offen |
| Desktop-Mehrspalten | späterer Fach-/UX-Stand | offen |
## 6.1 Implementierungsstand (MVP-Journal-Routen)
**Status: Code vorhanden, keine finale Start-IA.** Technische Umsetzung der Routen: `mvp_implementation.md` §6. Fit-Gap zu Produktstart und `/dialog`: `../functional/mvp_stand_und_abgleich.md`.
Die Shell-Nav bleibt Start / Dialog / Journal / Einstellungen. Der MVP-Slice füllt `/journal`:
- `/journal` Space-Liste
- `/journal/:spaceId` Chronologie
- `/journal/:spaceId/:dayId` Dialog, Generate und lokale Tagesstichpunkte
- `/journal/:spaceId/:dayId/entry` Fließtexteditor (Markdown, Inline-Bild/Video, Versionen, Dirty-Flag)
- `/journal/:spaceId/:dayId/source/:conversationId` Quelldialog
`/` bleibt dünn und verweist auf das Journal. `/dialog` ruft in der Testphase denselben Dialogzug auf wie der Journal Day (`POST .../turn`), bleibt aber ohne Space-Chronologie und ist nicht die zweite Produkt-IA. Home wird kein Dashboard. Der Journal-Editor führt ein Dirty-Flag; In-App-Navigation mit ungespeicherten Änderungen wird bestätigt. Stichpunkte: Return erzeugt eine neue Zeile und fokussiert sie.
Admin sieht unter dem Gespräch eine **Testspur**: intern gerenderter Prompt, maskierter Egress, Rohantwort, demaskierte Antwort, gewählte Dialogoperation. Keine Mapping-Tabelle. Nicht die produktive Nutzer-IA.
Die Dialogfläche bleibt in der Inhaltsäule der Shell. Lange Nachrichten und die Testspur umbrechen oder scrollen intern; sie dürfen die Seite nicht in der Breite aufweiten.
Mobile (unter 1024px): Bottom-Nav, Kopfzeile, volle Breite, Safe-Area, Gespräche als kompakte Umschalter oben, Verlauf scrollt, Eingabe darunter in Daumennähe, Stichpunkte unter dem Dialog. Desktop (ab 1024px): Sidebar, Lesespalte für Standardseiten, Dialogfläche voll in der Inhaltsäule, Stichpunkte rechts daneben. Kein weiterer Breakpoint. Der Journal-Tag zeigt den Dialog zuerst; Draft und Einträge nur als kurze Verweise. Mehrere Gespräche werden erst beim Erzeugen des Tagebuchs zur Wahl gestellt.
## 7. Offene Fragen
1. Welche Platzhalter-Routen bleiben in der ersten Rahmen-Kopie sichtbar, welche werden ausgeblendet, bis Fachseiten existieren?

View File

@ -136,6 +136,30 @@ Layer-0-Tabellen `usage_sessions` (nicht Auth-`sessions`), `conversations`, `mes
Nicht enthalten: Entry-IA, Fadenerkennung, Thread-Enum, Summaries über das Gateway.
## 6.2 Implementierungsstand (MVP-Journal-Slice)
**Status: Code vorhanden für den ersten vertikalen Slice.** Technische Umsetzung: `mvp_implementation.md`. Fachhome: `../functional/mvp.md`. Foundation: `../functional/implementation_foundation.md`. Fit-Gap (Code gegen Slice und Gesamtziel): `../functional/mvp_stand_und_abgleich.md`.
Zusätzlich zu Layer 0:
- `spaces` mit `visibility='user'` ist der sichtbare Reflection Space (anlegen, umbenennen, auswählen).
- `journal_days` ist die chronologische Bezugseinheit `(space_id, calendar_date)`. Kalendertag und `messages.created` bleiben getrennt. `scratch_json` hält optionale Tagesstichpunkte (lokal, kein KI-Kontext).
- `conversations.space_id` / `journal_day_id` sind Primary Assignment, nicht Identität. Alte Conversations ohne Assignment bleiben außerhalb des Journals.
- `journal_drafts` sind Derived: neue Generierung historisiert den vorigen Draft und lässt `journal_entries.current_version_id` unangetastet.
- `journal_entries` + `journal_entry_versions` tragen Current Validity und History; Soft-Delete über `deleted_at`. Übernehmen eines Entwurfs schreibt eine neue Version des bestehenden Entry (`origin=accepted_draft`), sofern nicht ausdrücklich ein weiterer Entry angelegt wird. Die aktuelle Fassung steht in der Versionsliste ohne Restore auf sich selbst.
- `media_assets` verweisen auf lokale Dateien unter `backend/data/media/`. Einbettung und Unterschrift stehen im Entry-Body als Markdown, nicht als eigene Caption-Tabelle.
- `writing_profiles` / `writing_profile_sources` halten den Current Brief. Quellenpriorität: finale Nutzerfassungen, Importe, Dialogstil (`dialogue_style` aus `user:`-Zeilen). Quellen tragen `occurred_at` und `context_hint`. KI-Drafts sind keine Stilquelle. Der Brief ist eine abgeleitete Sicht.
- `writing_profiles.lifecycle` (`uninitialized` | `initial_pending` | `confirmed`) trennt Korpus-Sammlung vom kontinuierlichen Lernen. `writing_profile_traits` / `writing_profile_trait_refs` sind die dynamischen semantischen Merkmale inkl. Evidence und Exemplaren. `writing_profile_facets` bleiben Layer-Hüllen (Core/context/output), kein festes Stilraster.
- `writing_profiles.governance` (`learning` | `advising` | `frozen`) und Locks verhindern stilles Voll-Überschreiben. `writing_profile_suggestions` trägt advising-Vorschläge (auch `trait_slug` / `action`).
- `writing_profiles.version` / `review_ready` / `last_reviewed` plus `writing_profile_evidence`, `writing_profile_reviews`, `writing_profile_versions` tragen die Review-Pipeline: lokale Evidenz, gebündelte Pakete, API- oder Paste-Kanal, nachvollziehbare Facet-Stände. Kein Re-Infer nach jedem Save.
- Portable JSON-Dokumente (`kansho.writing_profile`, `kansho.interaction_profile`, `kansho.profiles`) exportieren Facets/Prefs/Governance, nicht Journalquellen oder Identity-Mapping.
- `interaction_profiles` / `interaction_preferences` / `interaction_suggestions` sind getrennt vom Writing Profile. Default-Governance `advising`. Starke Quellen nur explizit/manuell/angenommener Vorschlag.
- `conversations.emotional_intensity` / `long_story` sind operative Dialogue-State-Felder, kein User-Fakt.
`derived_records` bleibt die generische Layer-0-Hülle und ist nicht der Journal-Store. `handoffs.target='journal'` ist kein Schreibpfad für Einträge.
Nicht enthalten: Threads-UI, Related Spaces, semantisches Retrieval, Session-Summaries, Voice, Space Content Memory. Der Dialogzug sieht denselben Journal Day vollständig und dazu die letzten Journal Entries des Spaces als Recency-Ausschnitte. Die Journalgenerierung bekommt denselben Tagesdialog inklusive Kanshō-Impulse als Gesprächskontext, nicht als Erlebtes.
## 7. Offene Fragen
1. IndexedDB vs. SQLite-WASM vs. nur Service-Worker-Queue?

View File

@ -0,0 +1,323 @@
---
title: "Kanshō Technische Umsetzung des MVP-Journal-Slices"
status: "Arbeitsstand"
date: "2026-08-25"
product_family: "Jinkendo"
document_role: "Technical Chapter / MVP Implementation How-To / Module and Runtime Map"
parent_document: "technische_zielarchitektur.md"
---
# Kanshō Technische Umsetzung des MVP-Journal-Slices
**Kanonisches Home** dafür, *wie* der erste vertikale Slice gebaut ist: Laufzeit, Module, Daten, API, Pfade.
Nicht dieses Dokument:
- Fachlicher Lieferumfang → `../functional/mvp.md`
- Foundation-Regeln → `../functional/implementation_foundation.md`
- Fit-Gap (Spec, Abweichungen, offene Punkte gegen Slice und Gesamtziel) → `../functional/mvp_stand_und_abgleich.md`
Kapiteldetails (Auth, Gateway-Verfahren, Shell-Muster) bleiben in den jeweiligen technischen Homes. Hier die zusammenhängende Umsetzung.
**Stand:** 2026-08-25, Code unter `frontend/` und `backend/`. Architecture-Correction-Pass am selben Tag (relationale Source-Refs, Selection-Specs, begrenztes Space-Source-Retrieval, Detect-Env, Response Validation, Konsolidierungssignale, `/dialog` als Admin-Harness).
---
# 1. Laufzeit
Lokal, eine Instanz, ein Profil-Nutzer nach Setup:
| Prozess | Stack | Port | Einstieg |
|---|---|---|---|
| Frontend | React 18, Vite, React Router 6, PWA-Plugin | **5188** (`strictPort`) | `frontend/``npm run dev` |
| Backend | FastAPI, Python 3.12, Uvicorn | **8018** | `backend/` |
| Persistenz | **SQLite** Datei | — | `backend/data/kansho.sqlite` |
| Medien | lokale Dateien | — | `backend/data/media/<profile_id>/` |
| Secrets | Env | — | `backend/.env` (Keys nie in der DB) |
Proxy: Vite leitet `/api` an 8018. CORS erlaubt `localhost:5188`.
**Abweichung vom technischen Zielrahmen:** `product_frame_and_stack.md` nennt PostgreSQL. Der Slice läuft auf SQLite. Lokal zulässig; Prod-Pfad offen. Siehe Fit-Gap.
Health: `GET /api/health`.
---
# 2. Schichten und Invariante
```text
PWA (React)
→ /api Session-Cookie
FastAPI-Router (kein Prompt-Bau im Frontend)
→ journal_policy / stores
→ context_builder + retrieval (intern, Klartext, nie Egress)
→ privacy_gateway (einziger generativer Egress)
Policy → Maskierung → Detect (Testphase) → Generate-Call → Validierung → Demask
→ SQLite + media/
```
Persönlicher Kontext geht nicht direkt an OpenRouter. Router rufen kein LLM. Fail Closed: kein unsicherer Generate-Fallback.
Zwei Provider-Rollen (`provider_settings`): `detect` (Maskierung) und `generate` (Dialogzug und Journalentwurf). URL/Modell/Flags in der DB, Key in `.env`. Admin: Schnittstellen.
---
# 3. Backend-Module
Einstieg: `backend/main.py` (Router: auth, users, dialogue, journal, prompts, placeholders, subscription, admin).
| Modul | Verantwortung |
|---|---|
| `routers/journal.py` | Produkt-API `/api/journal` |
| `routers/dialogue.py` | Low-Level-Source `/api/dialogue`; derselbe Turn-Pfad |
| `dialogue_turn.py` | Ein generativer Call, JSON `{operation, impulse}`, lokale Register-/Wächter, optional Repair |
| `journal_generate.py` | Expliziter Draft-Call |
| `journal_store.py` | Days, Drafts, Entries, Versionen, Scratch |
| `journal_policy.py` | Explizites Generate, Origins, Konsolidierungsangebot aus lokalen Signalen |
| `journal_body.py` / `journal_shape.py` | Titel, Markdown-Body, lokale Nachformung |
| `context_builder.py` | Interner Prompt-Kontext über Selection-Specs, keine Recency-API |
| `retrieval.py` | Austauschbare Specs: `day_messages`, `space_entries`, `space_recent_sources`, `writing_profile`. Recency/SQL-Kappen sind Implementierung. |
| `conversation_signals.py` | Lokale operative Metadaten (`narrative_mode`, `reflection_depth`, `current_focus`), kein Extra-LLM |
| `privacy_gateway.py` | Egress; Response Validation vor Demask |
| `entity_detect.py` / `pronoun_bind.py` / `identity_store.py` | Detection, Dialog-Pronomen, Mapping (Klasse A) |
| `writing_profile_store.py` | Lokaler Journal-Stilbrief, Facets, Governance, kein Extra-LLM, keine Dialogsteuerung |
| `profile_review.py` | Lokale Evidence-/Drift-Erkennung, Review-Pakete, API- und Paste-Kanal; kein Call nach jedem Dialog |
| `writing_profile_infer.py` | Lokale Heuristik für Facets, kein LLM |
| `media_store.py` | Upload, Datei, Token im Body |
| `providers.py` / `provider_settings.py` | Provider-Rollen |
| `db.py` + `schema.sql` | SQLite, Migrationen |
Prompts in der DB, nicht im Anwendungscode: `mvp.dialogue_turn`, `mvp.journal_generate`, `mvp.entity_detect`, `mvp.profile_review`. Seed: `backend/config/prompts.seed.json`. Unangetastete Templates aktualisiert `init_db`.
---
# 4. Daten (Ist, nicht Ziel-PostgreSQL)
Schema: `backend/schema.sql`. Isolation über `profile_id`.
**Rahmen / Layer 0:** `profiles`, Auth-`sessions`, `usage_sessions`, `conversations`, `messages`, Thread-/Space-Hüllen, `derived_records` (ungenutzt für Journal-Writes), `identity_mappings`, Prompt-/Feature-/Provider-Tabellen.
**Journal-Slice:**
| Tabelle | Rolle |
|---|---|
| `spaces` | Sichtbarer Reflection Space (`visibility='user'`) |
| `journal_days` | `(space_id, calendar_date)`; `scratch_json` lokal |
| `journal_drafts` | Derived; neue Generierung historisiert den Draft |
| `journal_entries` | Current über `current_version_id`; Soft-Delete `deleted_at` |
| `journal_entry_versions` | History; Restore hängt eine Version an |
| `journal_draft_source_refs` / `journal_entry_version_source_refs` | Relationale Provenance (Conversation/Message-IDs). JSON-Listen sind Altbestand und werden beim Start migriert. |
| `media_assets` | Datei + Kind; Referenz nur im Body `![…](kansho-media:<id>)` |
| `writing_profiles` / `writing_profile_sources` | Current Brief |
| `writing_profile_evidence` / `writing_profile_reviews` / `writing_profile_versions` | Review-Pipeline: lokale Evidenz, Paket/Ergebnis, Versionen |
Assignment: `conversations.space_id` / `journal_day_id` sind Felder, nicht Identität. `space_id` ohne `journal_day_id` ist zulässig (Space ≠ Journal-Container). `conversations.space_id` ohne FK. Kalendertag ≠ `messages.created`. Operative Dialogsignale liegen als Felder auf `conversations`, nicht als Writing Profile.
Details: `memory_storage_and_offline.md` §6.16.2.
---
# 5. API (Produkt)
Prefix `/api/journal`, Session-Auth, Router `backend/routers/journal.py`. Vollständige Liste: `backend_and_api.md` §5.1.
Kernpfade:
1. **Dialogzug** `POST /days/{id}/conversations/{cid}/turn` und `POST /api/dialogue/conversations/{id}/turn`
Context Builder → Gateway (`purpose=dialogue_turn`) → ein Generate-Call → Impuls speichern. Pronomenbindung nur hier in `user:`-Zeilen.
2. **Journalentwurf** `POST /days/{id}/generate`
Nur explizit. Optional `conversation_ids`. Ohne Auswahl kein stilles Mergen. Gateway `purpose=journal_generate`. Pronomen ungebunden. Entry-`current_version_id` bleibt unangetastet. UI öffnet `?draft=1`.
3. **Speichern** `POST /entries`
Mit `entry_id` + `origin=accepted_draft`: neue Version desselben Entry. Sonst neuer Entry oder `user_edit`.
4. **Restore** `POST /entries/{id}/restore` → neue Version, Origin `restore`.
5. **Scratch** `PATCH /days/{id}/scratch` → nicht im Context Builder, Test: Token nicht im Egress.
6. **Media** Upload/GET/DELETE unter dem Entry.
7. **Profile Review** `GET/POST /writing-profile/review…` → lokale Evidenz, explizite API- oder Paste-Review, kein Call am Dialogzug.
`include_existing` existiert in der API, die UI setzt es nicht.
Fehlerkörper oft `{detail: {code, message}}`, Rahmen will `{detail: string}` — Abweichung, Fit-Gap.
---
# 6. Frontend-Umsetzung
Shell: `frontend/src/App.jsx`, Nav `config/appNav.js` (Start / Journal / Einstellungen; Admin extra). `/dialog` ist nicht in der Produkt-Nav. Breakpoint 1024px: Bottom-Nav vs Sidebar. Details: `frontend_pwa_shell.md` §6.1.
| Route | Seite | Technik |
|---|---|---|
| `/` | `HomePage.jsx` | Stub, Link zum Journal |
| `/dialog` | `DialoguePage.jsx` | **Admin-only Dev/Test-Harness**, nicht in der Produkt-Nav. Unzugeordnete Conversations. |
| `/journal``/journal/:spaceId/:dayId` | Spaces, Space, `JournalDayPage.jsx` | Chat, Generate, `DayScratch.jsx` |
| `…/entry` | `JournalEditorPage.jsx` | TipTap, Markdown in `journal/document.js`, Dirty-Flag `context/UnsavedChanges.jsx` |
| `…/source/:conversationId` | `JournalSourcePage.jsx` | Quelldialog |
| `/settings` | `SettingsPage.jsx` | Writing/Interaction, Review-Paket (API oder Copy/Paste) |
Editor speichert Markdown, nicht HTML. Medien-Token bleiben lokal. Dirty: In-App-Links, Abmelden, `beforeunload`; Browser-Zurück nicht hart blockiert.
---
# 7. Zwei fachliche Calls, drei technische Schichten
| Schicht | Rolle | Call |
|---|---|---|
| Maskierung | `detect` | Muster immer. Externes Klartext-Detect nur Development/Test (`KANSHO_ENV`). Produktiv: Muster oder lokale Detect-URL. |
| Dialogzug | `generate` | Operation + Impuls |
| Journalentwurf | `generate` | Explizit, getrennt |
Explizite Writing-Profile-Review ist ein weiterer Generate-Zweck (`profile_review`), nicht der Dialogzug und nicht nach jedem Turn.
Interner Context-Builder (Klartext, lokal): fordert Selection-Specs an (`day_messages`, `space_entries`, `space_recent_sources`, `writing_profile`). Recency-Kappen (Day 80 Messages; 5 Space-Entries × 400 Zeichen; 3 Space-Source-Conversations × 400 Zeichen User-Ausschnitt) liegen in `retrieval.py`, nicht in der Context-Builder-API. Dialogzug: aktuelle Conversation primär, plus begrenzte frühere Original-Conversations desselben Space (ohne denselben Journal Day voll zu laden). Generate ohne Prior-Entries, mit Writing Profile. `WritingProfile` steuert Journaltext, nicht den Dialogue Kernel; der Turn hat einen leeren `interaction_hint`-Slot. Retrieval austauschbar, keine Embeddings.
Gateway-Verfahren und offener Security Layer: `privacy_gateway.md` §9.19.2.
---
# 8. Tests (technisch)
| Suite | Ort |
|---|---|
| Rahmen, Auth | `backend/tests/test_frame.py` |
| Journal-Slice, Scratch-Egress, Versionsschutz | `backend/tests/test_mvp_journal.py` |
| Architecture Correction (Provenance, Space-Source, Detect-Env, Kernel-Grenzen) | `backend/tests/test_architecture_correction.py` |
| Detect, Pronomen Dialog vs Journal, Response Validation | `backend/tests/test_privacy_detect.py` |
| Shape / Namen | `backend/tests/test_journal_shape.py` |
| Writing Profile | `backend/tests/test_writing_profile.py` |
| Profile Governance | `backend/tests/test_profile_governance.py` |
| Profile Review Pipeline | `backend/tests/test_profile_review.py` |
| Writing-Profile-Hülle / Initial Build | `backend/tests/test_profile_review.py`, `backend/tests/test_profile_governance.py` |
| Markdown-Runde | `frontend/src/journal/document.test.js` |
Tests beweisen API-Verträge und Invarianten, nicht Dialogqualität.
---
# 9. Technische Abweichungen und nicht gebaut
Nur die **technische** Lage. Bewertung gegen Spec und Vision: Fit-Gap.
| Thema | Ist | Zielrahmen / Produktiv |
|---|---|---|
| DB | SQLite Datei | PostgreSQL |
| Detect | Muster immer; externes Klartext-Detect nur Development/Test; Produktiv fällt auf Muster zurück. Lokales HTTP-Detect bleibt Provider-Rolle. | lokales Modell (Ollama) als Ziel |
| Fehlerkörper | oft Objekt | `{detail: string}` |
| `/dialog` | Admin-Harness, aus der Produkt-Nav entfernt | nicht MVP-Screen |
| Response Validation | blockiert Klartext-Identität in der Rohantwort vor Demask; keine Quasi-Identifikatoren | `privacy_gateway.md` §9.2 voll |
| Security Layer | Pfad da; Minimierung/Audit/Encryption fehlen weiter | `privacy_gateway.md` §9.2 |
| Offline-Sync, Voice-Egress, Compose-Prod | ungebaut | jeweiliges Kapitel |
Nicht im Code: Threads-UI, semantisches Retrieval, Session-Summaries, Related-Space-UI, Papierkorb-UI, lokales Detect-Modell, Obsidian/mindnet/Kairo-Handoff.
---
# 11. Korrigierter Ist-Stand vs. bewusst offene Target-Fähigkeiten
Additiv zum Slice, 2026-08-25. Kein Target-Model-Vorbau.
**Korrigiert im Ist:**
- Provenance von Draft und Entry-Version ist relational (`journal_*_source_refs`); JSON-ID-Listen werden verlustfrei migriert. Source-Messages bleiben unangetastet.
- Context Builder spricht Selection-Specs; Recency bleibt die aktuelle Retrieval-Heuristik, nicht die fachliche API.
- Dialogzug kann begrenzte jüngere Original-Conversations desselben Space sehen, ohne denselben Journal Day voll zu laden.
- Externes Klartext-Detect ist kein Produktmodus (`KANSHO_ENV=production` fällt auf Muster zurück). Lokales Detect bleibt austauschbare Rolle.
- Response Validation blockiert Klartext-Identität vor Demask.
- Konsolidierung nutzt lokale Signale, nicht `count >= 2`.
- `/dialog` ist Admin-Harness, nicht Produkt-IA.
- `WritingProfile` hängt nicht am Dialogue Kernel; `interaction_hint` ist ein leerer Slot.
- Conversation kann einem Space ohne Journal Day zugeordnet werden.
**Bewusst offen (nicht in diesem Pass gebaut):**
- Semantisches/globales Retrieval, Threads, Self Model, Related Spaces, mindnet
- Interaction-Profile-Engine, UI und Persistenz
- Reflection-Space-Typen, Journal-Default am Gesprächsabschluss
- Lokales Detect-Modell, Audit ohne Prompt, Encryption, inhaltliche Minimierung
- Stub-Home / Contextual Continuation als Start-IA
---
# 12. Profile Governance (2026-08-25)
Additiv zu §11. Kein Self Model, keine Intent-Engine, kein ML-Training.
**Writing Profile**
- Ein Profil als Hülle: Core, optionale Facets, dynamische Traits. Die früheren Stilschlüssel sind Seed, nicht Schema. `compiled_brief` bleibt abgeleitete Sicht aus Traits und zeitgestempelten Quellen.
- Governance `learning` | `advising` | `frozen`. Automatische Pfade (Turn, Save, Import, Bootstrap) überschreiben keine Locks und keine manuellen/angenommenen Facets. `frozen` hält Facet-Werte. Explizites Rebuild ehrt Locks.
- Rückwirkender Bootstrap aus gespeicherten Current-Entries. Unredigierte Drafts bleiben keine Stilquelle. Dialog ist schwächer und entfällt als Stimme, sobald Entries existieren.
**Interaction Profile**
- Getrennt, Default `advising`. Produktdefaults (Antwortlänge, ein Anschlussimpuls, Zuhören bei Erzählung) sind gelabelt und überschreibbar.
- `{{interaction_hint}}` füllt sich aus Defaults + persönlichen Prefs + aktuellem Dialogue State. Nicht-Widersprechen ist kein Lernsignal. `propose_observation` legt höchstens Suggestions an und wird nicht aus `run_turn` aufgerufen.
- Keine Intent-Taxonomie. Reflection Intent bleibt ungebaut. Dialogue State (`narrative_mode`, `reflection_depth`, `current_focus`, `emotional_intensity`, `long_story`) bleibt automatisch.
Kleine Settings-Erweiterung: Lifecycle/Initial Build, dynamische Traits statt festem Facet-Raster, Prefs, Vorschläge. Keine Profile-Management-App.
JSON-Portabilität (additiv): `kind` `kansho.writing_profile` / `kansho.interaction_profile` / `kansho.profiles`, `format_version` 1. Restore ersetzt Facets/Prefs und Governance ausdrücklich; Journalquellen, Dialoge und Identity-Mapping gehören nicht ins Dokument.
---
# 13. Profile Review Pipeline (2026-08-25)
Additiv zu §12. Keine neue Profil-Engine.
Nach Save/Import/Turn werden semantische Traits **nicht** neu berechnet. Der frühere Bootstrap-Infer über Zählfacetten entfällt; Quellen werden nur assembliert. Kontinuierliche Queue erst nach bestätigtem Initial Build (§14). Danach:
1. lokale Evidence-/Drift-Erkennung (billig, ohne LLM)
2. Review Candidates in `writing_profile_evidence`
3. semantische AI Review nur explizit, Evidenzen gebündelt
4. Governance entscheidet die Übernahme (`learning` wendet unlocked an, `advising` queued Suggestions, `frozen` nicht)
5. `writing_profile_versions` hält den Stand nachvollziehbar
Writing-Trigger: starke User-Änderung eines AI-Drafts, heuristische Abweichung, Stichprobe längerer Dialogtexte (Cooldown, kein LLM), periodische Bereitschaft nach Zeit/Volumen, explizite Nutzer-Review.
AI Review vergleicht Evidenz mit dem bestehenden Profil und liefert Änderungsvorschläge (`keep`/`update`), kein komplettes neues Profil. Paket unterscheidet `core` und `facet` und darf repräsentative Source-Beispiele an Merkmalen führen.
API (`POST /writing-profile/review/api`) und Copy/Paste (`POST .../paste` plus `.../import`) nutzen denselben Vertrag: `kansho.profile_review_package` / `kansho.profile_review_result`. Paste erzeugt lokal einen kopierbaren Prompt; das Ergebnis kommt strukturiert zurück. Interaction-Präferenzen entstehen nicht aus fehlendem Widerspruch oder normalem Gesprächsverlauf.
Das statische Facet-Raster aus §12 ist **nicht** die Profilstruktur. Semantische Traits und der Initial Profile Build: §14.
---
# 14. Writing-Profile-Hülle und Initial Build (2026-08-25)
Additiv zu §12 und §13. Keine neue große Engine.
Stabile Hülle, nicht ein geschlossenes Merkmalsraster:
1. Core
2. optionale context/output-Facets
3. darin dynamische semantische Traits (datengetriebene Slugs)
4. Evidence References
5. Representative Exemplars
6. Governance
7. Versioning
`rhythm`, `humor`, `detail`, `chronology` und verwandte Keys bleiben **Seed-/Ordnungshilfe** (`seed_catalog`). UI, Persistenz und Review binden sich nicht an diese Liste. Lokale Heuristiken (`writing_profile_infer`) sind Mess- und Triggerhilfe; sie schreiben keine Traits.
Lifecycle: `uninitialized``initial_pending``confirmed`. Quellen (Journal und Import) tragen `occurred_at` und `context_hint`. Neuere Texte wiegen stärker für die aktuelle Ausprägung, ältere belegen Langzeitmerkmale. Urlaubstagebücher mappen auf `autobiographical_journal`. Ein globaler Core wird nur geschrieben, wenn der Korpus unterschiedliche Quellenarten trägt.
Initial Profile Build liest den historischen Korpus (nicht nur zwei Entries). Review-Aktionen folgen Existing-before-New: bestätigen, präzisieren, Scope ändern, zusammenführen/aufteilen, erst dann neu anlegen. Kontinuierliches Learning/Advising (Queue, Drift, Dialog-Sample) startet erst nach bestätigtem Build.
API additiv: `POST /writing-profile/corpus`, `GET /writing-profile/corpus`, `PATCH /writing-profile/traits/{slug}`, `POST /writing-profile/initial-build/paste`, `POST /writing-profile/initial-build/api`. Paketvertrag bleibt `format_version` 1 mit zusätzlichen Feldern `mode`, `corpus`, `existing_before_new`, `seed_catalog`.
Tests: `backend/tests/test_profile_governance.py`, `backend/tests/test_profile_review.py`.
---
# 10. Querverweise
- Fit-Gap: `../functional/mvp_stand_und_abgleich.md`
- API-Liste: `backend_and_api.md` §5.1
- Tabellen: `memory_storage_and_offline.md` §6
- Egress: `privacy_gateway.md` §9
- Routen: `frontend_pwa_shell.md` §6.1
- Stack-Ziel: `product_frame_and_stack.md`
- Ports: `runtime_and_deploy.md`

View File

@ -109,7 +109,7 @@ Fachliche Reflexions-Workflows (Tagesreflexion, Journal-Entwurf, …) werden **s
- Debug-Responses mit Klarnamen oder vollem Internal Context.
- Vermischung von `{{key}}` (Kontext) und `[[ROLE]]` (Privacy).
Modellname darf vorerst Env-Default sein; Routing-Policy später über Konfiguration, nicht über neue Executoren.
Modellname und Endpoint-URL liegen in Admin-Konfiguration (`provider_settings`); Secrets bleiben Env. Routing-Policy später über Konfiguration, nicht über neue Executoren.
---
@ -123,6 +123,7 @@ Ziel wie Mitai: **Einfachheit in der Nutzer-UX, Differenzierung in administrierb
- Feature-Definitionen und Limits (wenn Entitlements genutzt werden)
- UI-Katalog: welche Karten/Nav-Beiträge sichtbar, Layout-Overrides
- System-Prompts vs. Instanz-Anpassungen (Reset auf Default)
- Provider-URL, Modell und ZDR/No-Train für Generate und Detect (`provider_settings`, Admin Schnittstellen)
- später: Privacy-Profile (Strict/Balanced), sobald fachlich konkret
### 4.2 Code / Env (nicht pro Klick im Admin)

View File

@ -101,7 +101,55 @@ Interview H1 (Encryption at rest, vollständiges Löschen, Portabilität, DSFA b
| OpenRouter | Kandidat, nicht Gateway-Ersatz | entschieden |
| EU-Routing | bevorzugt | offen in Ausprägung |
| Encryption at rest, Delete, DSFA | Phase H | offen |
| Implementierung Entity Detection | deterministisch vs. KI-gestützt | offen |
| Implementierung Entity Detection | Wörterbuch + optionaler Detect-Provider; Qualität offen | bevorzugte Richtung im Code, nicht abgeschlossen |
## 9.1 Implementierungsstand (MVP-Egress)
**Status: Code vorhanden. Zwei getrennt konfigurierbare Provider.** Fit-Gap und Guardrail-Abstand (Detect-Testphase, Security Layer): `../functional/mvp_stand_und_abgleich.md`.
`backend/privacy_gateway.py` ist die einzige Stelle für den **generativen** Egress. Reihenfolge: Policy-Check → lokale Wörterbuch-Maskierung mit Wortgrenzen (kein Teilstring, kein `ich` in `nicht`) → Entity Detection nur auf user-Zeilen (Muster + Wörterbuch, in der Testphase zusätzlich OpenRouter-Detect) → Remaskierung: im Dialogzug nur `user:`/`assistant:`-Zeilen, beim Journalentwurf der ganze Prompt → ein Generative-Call nur mit Platzhaltern → Antwortprüfung → Demaskierung.
Detect-Vorschläge werden nur gespeichert, wenn das Wort im user-Text steht, kein Funktions- oder Stoffwort ist und nicht zum Promptvokabular gehört. `mit` plus Allerweltsnomen (`mit Getöse`) ist kein Personenname; `mit Anna` bleibt einer. Bereits gespeicherte unzulässige Labels werden beim nächsten Detect entfernt.
Intern in der Testspur ist keine dritte Anfrage, sondern die lokale Klartext-Vorlage desselben Dialogzugs. Detect und Dialogzug bleiben getrennte Calls.
Pronomen werden nicht als eigene Detect-Treffer gespeichert. Nach der Namensmaskierung bindet eine lokale Schicht **im Dialogzug** in `user:`-Zeilen `er`/`sie`/`ihm`/`ihr` an das letzte kompatible Person-Token, damit das Sprachmodell dieselbe Person sieht. **Beim Journalentwurf bleiben diese Pronomen ungebunden**, sonst macht die Demaskierung aus jedem Bezug den Klarnamen. Anrede-`ihr` und pluralisches `sie` bleiben ungebunden. Dieselbe Person behält ein Token; `PERSON:KURZ` ist kein Mapping. Ein Personenname, der auch eine Sache ist, wird bei Verwandtschaftswort maskiert und bei Verben wie essen/kochen/kaufen davor oder danach als Sache stehen gelassen (`Sushi essen`, `esse Sushi`).
Zwei Provider, getrennt konfigurierbar (`providers.py`, Admin **Schnittstellen**):
Drei Schichten, zwei technische Endpunkte (`provider_settings`-Rollen `generate` / `detect` sind Implementierungsnamen):
- **Maskierung** (Rolle `detect`): nur schützenswerte Muster. Keine Operation, kein Impuls, kein Journaltext. Testphase: OpenRouter-Detect auf user-Zeilen. Produktivziel: Muster + lokales Modell. Wiederverwendbar für spätere Dialogtypen.
- **Sprachmodell** (Rolle `generate`): maskierter Kontext. Dient zwei fachlichen Calls: **Dialogzug** (Operation + Impuls) und **Journalentwurf** (explizit). Das größere Modell gehört hierhin, nicht an die Maskierung.
URL, Modell und Policy liegen in `provider_settings` (Seed `config/providers.seed.json`, Admin `GET/PUT /api/admin/providers`). Keys nur in `backend/.env`, nie in der DB und nie in der API-Antwort. Env überschreibt Admin-Werte, wenn gesetzt.
Lokale URLs (localhost / 127.0.0.1) brauchen keine ZDR-Flags. Die Mapping-Tabelle bleibt Klasse A und lokal; neue Treffer werden dort persistiert, damit der nächste Prompt dieselben Platzhalter per Muster nutzen kann, ohne Detection-Call.
Klasse A verlässt die Zone nicht. Mapping wird nicht mitgeschickt. Kein stiller Fallback auf einen unsicheren Generate-Provider.
Prompts für Dialog, Journal, Detection und Profile-Review liegen in der DB (`mvp.dialogue_turn`, `mvp.journal_generate`, `mvp.entity_detect`, `mvp.profile_review`), nicht im Anwendungscode.
`mvp.dialogue_turn` bleibt ein generativer Call (Foundation: ein Hauptcall pro Turn). Ton, Tiefe und Erzählmodus kommen lokal als `register_hint` plus Writing-Profile-Hinweis in denselben Prompt, nicht als zweites Modell. JSON `{operation, impulse}`. Persistiert wird nur `impulse`.
Admin-Testspur (`trace`) zeigt intern gerenderten Prompt und maskierten Egress. Das ist ein bewusster Test-Einblick, nicht der fachliche Default aus §2 (Diagnose ohne volle Prompts). Mapping-Tabelle bleibt ausgeschlossen.
## 9.2 Offene Implementierungspunkte (Security Layer)
**Status: bewusst offen.** Der MVP-Pfad ist durchgängig, der fachliche Security Layer aus `../functional/guardrails.md` ist **nicht vollständig** implementiert. Für Testdaten akzeptabel; nicht als abgeschlossen führen.
Noch offen im Code:
1. **Inhaltliche Context Minimization.** Es geht der zusammengebaute Dialog, nur durch eine Zeichenkappe begrenzt. Minimum-sufficient-context nach Aufgabe fehlt.
2. **Robuste Entity Detection.** Wörterbuch plus Muster plus Detect-Call. Testphase: externes kleines Modell über OpenRouter. Ziel: lokales Ollama. Keine belastbare Erkennung von Quasi-Identifikatoren (Beruf + Ort + Familie, seltene Konstellationen).
3. **Response Validation.** Es wird nur auf leere Antworten geprüft. Keine Prüfung, ob Platzhalter erhalten blieben oder das Modell Klarnamen erraten hat.
4. **Audit ohne Prompt-Inhalt.** Es gibt keine persistente Audit-Spur (Request-ID, Policy, Provider, ZDR-Status, maskierte Entitätstypen) ohne volle Prompts oder Mapping.
5. **Mapping-Härtung.** `identity_mappings` ist lokal, aber unverschlüsselt; keine Pflege-UI; keine Verschlüsselung at rest.
6. **Detect-Klartext in der Testphase.** Der Detect-Provider darf Klartext sehen. Bei externem Detect (OpenRouter) ist das ein bewusster Übergang, nicht der Zielpfad. Produktiv nur lokales Modell.
7. **Weitere Egress-Klassen.** Tool-, Web- und Speech-Egress bleiben getrennt und ungebaut.
8. **Löschen, Export, DSFA.** Kein Hard-Delete, keine Portabilität, keine Datenschutz-Folgenabschätzung.
Diese Punkte dürfen nicht stillschweigend als erledigt gelten, nur weil Generate bereits maskiert.
## 10. Offene Fragen

View File

@ -63,6 +63,8 @@ Krisengrenzen und medizinische Hochrisikothemen sind **nicht** dieses Kapitel (`
2. Backup-Verschlüsselung und Schlüsselhaltung.
3. DSFA und Retention, sobald Mehrbenutzer über den privaten Betrieb hinausgeht.
Der implementierte Privacy-Pfad ist durchgängig, aber nicht der vollständige Security Layer. Offene Implementierungspunkte (Minimierung, Detection-Qualität, Antwortprüfung, Audit, Mapping-Härtung, Detect-Klartext in der Testphase) stehen in `privacy_gateway.md` §9.2.
## 8. Querverweise
- `auth_identity_and_roles.md`, `privacy_gateway.md`, `runtime_and_deploy.md`

View File

@ -63,6 +63,8 @@ Der aus Mitai übernommene Produktrahmen (PWA, Auth, Rollen, Layout-Shell, Deplo
Ein späterer MVP bleibt eine fachliche Entscheidung (Interview Phase I). Die dort genannte kleine Reflexionsschleife ist **aktueller Arbeitsstand**, kein technisch eingefrorener Lieferumfang. Die leere oder fast leere Mitai-Shell ist Infrastruktur, nicht der MVP.
**Additiv 2026-08-25:** Der erste vertikale Journal-Slice ist im Code vorhanden. Der fachliche Lieferumfang bleibt `../functional/mvp.md`. Die **technische Umsetzung** (Laufzeit, Module, API, Daten) steht in `mvp_implementation.md`. Der Ist-Stand gegen Spec und Gesamtziel (Fit-Gap) steht in `../functional/mvp_stand_und_abgleich.md`. Technische „Implementierungsstand“-Abschnitte in den Einzelkapiteln sind Ausschnitte; sie ersetzen diese beiden Homes nicht. Der MVP-Schnitt in der Tabelle unten bleibt als *fachliche* Freeze-Frage Arbeitsstand; er ist nicht „nicht gebaut“.
---
## 3. Dokumentationsprinzipien
@ -188,6 +190,7 @@ integrations_technical.md
admin_diagnostics.md
voice_and_media.md
security.md
mvp_implementation.md
```
| Datei | Kanonisches Thema | Reife v0.1 |
@ -206,6 +209,7 @@ security.md
| `admin_diagnostics.md` | Diagnoseansicht | Mitai-Admin-Realm; Kanshō-Inhalte später |
| `voice_and_media.md` | Sprache / Transkription | Bedarf im Fachstand, Ausprägung offen |
| `security.md` | Rate Limit, Secrets, Encryption | Teil entschieden, Rest offen |
| `mvp_implementation.md` | Technische Umsetzung des Journal-Slices (Ist-Code) | Code 2026-08-25 |
---

View File

@ -43,6 +43,12 @@ Nicht entschieden, daher kein Implementierungszwang in v0.1:
- Fail Closed: kein unsicherer Cloud-Speech-Fallback.
- Journal- und Writing-Profile laufen auf Text nach Korrektur, nicht auf Roh-ASR-Fehlern als „persönlicher Stil“.
## 4.1 Journal-Medien (nicht Voice)
Spracheingabe bleibt offen. Davon getrennt: im Journal-Editor eingebettete Bilder und Videos sind lokale Dateien (`media_assets`), Auslieferung nur an die Session, kein Provider-Egress. EXIF bei JPEG/PNG wird gestrippt. Video-Container-Metadaten (einschließlich GPS) werden derzeit **nicht** entfernt.
Das ist keine Videoverwaltung und kein Ersatz für Transkription.
## 5. Entscheidungsstand
| Thema | Stand | Status |

View File

@ -8,10 +8,17 @@
"name": "kansho",
"version": "0.1.0",
"dependencies": {
"@tiptap/extension-placeholder": "^3.30.3",
"@tiptap/extension-underline": "^3.30.3",
"@tiptap/pm": "^3.30.3",
"@tiptap/react": "^3.30.3",
"@tiptap/starter-kit": "^3.30.3",
"lucide-react": "^0.383.0",
"marked": "^18.0.10",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1"
"react-router-dom": "^6.23.1",
"turndown": "^7.2.4"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.0",
@ -67,7 +74,6 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@ -2002,6 +2008,34 @@
"node": ">=12"
}
},
"node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/core": "^1.8.0",
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT",
"optional": true
},
"node_modules/@isaacs/cliui": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
@ -2073,6 +2107,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mixmark-io/domino": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
"license": "BSD-2-Clause"
},
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@ -2576,6 +2616,449 @@
"win32"
]
},
"node_modules/@tiptap/core": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.30.3.tgz",
"integrity": "sha512-kDD8KY99lBCKntCqTBE9eNR1ul/i/wPFw2METWT+LYZvifljXq2oiX6JaGF1Sk59efe7+sq9IISxOk47YlBiWQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "3.30.3"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.30.3.tgz",
"integrity": "sha512-Dh8yfEqBKTqEdBKZ4Ta3DkTecP0VJxvRNA5b1LCF7gBbe54Lm3A28UuVvK/Sdhh9FJDrIVCQTDS0yHGbi6G9ew==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.30.3",
"@tiptap/pm": "3.30.3"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.30.3.tgz",
"integrity": "sha512-a4BSAjWRN4mWklRTdDCIaG9R+PqyUxXNYGQ2CLtOhO51Wq6daw2qFTHR+cTqAjaiiUSuDYII86UChb2qQRq8Lg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.30.3"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.30.3.tgz",
"integrity": "sha512-YryCf9fq+9n1XIW6f5weSCD2MbL/LhRQ7jArRToQQ/oNxc422LqYVjijQQTNuk2cZEqE9fdO3Hw27tmwEEs9UQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.30.3",
"@tiptap/pm": "3.30.3"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.30.3.tgz",
"integrity": "sha512-Z/ZqUfrd3Fd8hHpHEPv3XRzqwqVLD4CP67xFMyjy0cqEgnjgvd1iR6QzJsKD79V9zyu0Agu7ab9ZBf4NktpWcw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.30.3"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.30.3.tgz",
"integrity": "sha512-mOV4Fg+ji6uXmFxTMuug9WK6zk0ksNQepnuvoDR4g4d+tnz2y9iEbgOXFohVxhOXOLDLdNeKdwHxbHQdi5Ivbw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.30.3"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.30.3.tgz",
"integrity": "sha512-xT2cDil/ipy/LklPgv/JqSsXys1mjcqlIPDUetK3llB2DT7XZssYPV3qv278tqRO4pJDbrXk82I6Lh7vLwmIAg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.30.3",
"@tiptap/pm": "3.30.3"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.30.3.tgz",
"integrity": "sha512-B9gqrgM1uHjCKr/PSnnSl+bS+YQb5EN/PUW9KWp9nSgmIaqxXws2ufqowS65uTLJLqd7pwF4l5lUrsfhq0EKzw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.30.3"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.30.3.tgz",
"integrity": "sha512-xdY66lcQakBLvWbJQHeJy/Td0f10YRXLyFeOFzt9PODsX4nqwJMju9tpqcLIGp0waaztu8PGwZ81oSCdQldIHQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.30.3"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.30.3.tgz",
"integrity": "sha512-4l5Qee1wBk2YBFEFbWHFhUt++6hDY7Yp81BkO3wY6KFToxs+cm9JIm3xr3KF9UJ/vnUpIhBWIbZl3uKf37SJLA==",
"license": "MIT",
"optional": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "3.30.3",
"@tiptap/pm": "3.30.3"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.30.3.tgz",
"integrity": "sha512-+mwm64+RiArd1G8xSgtaTrvx1X5Lzz6sJ93zTmaiMVNc2N29AN77BVPTJ0e+853+/F6Bec/vOnVkIjTLg/ZBRw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.30.3"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.30.3.tgz",
"integrity": "sha512-/tX+IFW2C4RJltbwcn8/4zoHwj5YycRNOugb4Ul9Fl1AOJqjNwRx8xc0NpaOjuADhlMYMczEtSaOiDh5ZEOmjg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.30.3"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.30.3.tgz",
"integrity": "sha512-y+0u9qdrUAOGNrP52hAEiH1mvr1ZmzmkCBICICiXRH4+QNXpKkiBK3zlbDOPVQj0g0YIg6XIIU0vIaUBDPIaMQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.30.3"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.30.3.tgz",
"integrity": "sha512-J/ioKlXu5oJ+pPtybFonByx1LusbR0PUd1nkx6XsZqY9wyW6uecZQgAyI8KUWIXqOfhnAW38UTsHMBRdm2XupA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.30.3",
"@tiptap/pm": "3.30.3"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.30.3.tgz",
"integrity": "sha512-dVWqJ/kDXdPHRBp4nU7bwi2G+c3oNruIgQHYVRgYlWkwW/opvnlACZLBUgEhihH7ANiSaGW1zLQ7HuUDSL3vog==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.30.3"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.30.3.tgz",
"integrity": "sha512-MAAXfLJNf6ZFiFK3w51yiLKc31HAQWJWNV+nQ1dAdRpa6WiXdjpkcrWTuxmPd/o6Qt+yNv+hoItAVxDPEox8qQ==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.30.3",
"@tiptap/pm": "3.30.3"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.30.3.tgz",
"integrity": "sha512-cCX99WoVb4UyXkamSh0vlXiRFhlZnwk3WXWdnXpR1E9+auxsQewk2Q9NDW1Ra0vwgzer1gkFhlOl9TCVzH2VVg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.30.3",
"@tiptap/pm": "3.30.3"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.30.3.tgz",
"integrity": "sha512-RhSyDlAATvXDTg/1VOw5aFTaewdCm+BAU3EG3eUCw4e5eoZ5pbhPOdOPTtaO45UR4i/AvjQNAZQ9LMDWX5WZFQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.30.3"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.30.3.tgz",
"integrity": "sha512-u6tOpQFnE1pOyvnzI2MvhW/Yw0omAIMlMfkykYx4N4zB1JDi+16OeOuKf3rX6G3V9VUu+qeOIGpSkifkME6H9Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.30.3"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.30.3.tgz",
"integrity": "sha512-lct/FV8vm9Y5kZFcF5TXIM18opi6m7AAkGoChgGP/A7LYalaniX1X6Ja2j13Y22ZdGaOoPTgd8oKRZXP1/miaA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.30.3"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.30.3.tgz",
"integrity": "sha512-Z9OHCX9b2bDcMDeR3ODCFRAyHT5eNGO4AsDFeAJS/XSitffMyjFHtOkXlyyZ3+pIHxxX6xkUXAwaLMjyATRo1A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.30.3"
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.30.3.tgz",
"integrity": "sha512-B7LwMzeQajQ18FWPGYv2UDh64HSUfXxh/mdIhS6ZQEDJS9NyUD0hPPnwaMQ1w870L/APgJdb2/EGqIkZqjJRSg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.30.3"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.30.3.tgz",
"integrity": "sha512-e9BI8Hzei23GjmYysXxL+CHq3fIgOm29BbSUMbYXxT2cJ+TkkgJp7EvHf74gSABNME0gUN+mG0pRR5wTFoOCcg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.30.3"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.30.3.tgz",
"integrity": "sha512-Jh8ZyI0HLOJOIPZl5a5XRJbTs4pAUakYIWCZ3Hv3lF9ouXW/C0bY67XQU7bv6Rvu8AHzDszhKklnOZ6z1NQPXQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.30.3"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.30.3.tgz",
"integrity": "sha512-TiZ+b524Ee4PNlW+HNmkg3x4btR1K6btJy9pEjMuDf0Rx01UaTBvHpHmABR/DbbcC1CuZiQ3ylJwLL+90PaEoA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.30.3"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.30.3.tgz",
"integrity": "sha512-vig8ZjUL/NFnniEVbYDYj2UOIdkwZVha+9myFZkanShedviE3kl7c4uCY3fMl5EnqgyFIy2TQSKbg/i99xpztA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.30.3",
"@tiptap/pm": "3.30.3"
}
},
"node_modules/@tiptap/pm": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.30.3.tgz",
"integrity": "sha512-VheaqLAFUe+PCYEgHubM96Z1OAiluFhijQ2Cy1Ghiozoxm5OzevSaKhqm8SQ1VGfzZZko59oUjRzOmu8tB8yfA==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.4.1",
"prosemirror-commands": "^1.7.1",
"prosemirror-dropcursor": "^1.8.2",
"prosemirror-gapcursor": "^1.4.1",
"prosemirror-history": "^1.5.0",
"prosemirror-inputrules": "^1.5.1",
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.11",
"prosemirror-schema-list": "^1.5.1",
"prosemirror-state": "^1.4.4",
"prosemirror-tables": "^1.8.5",
"prosemirror-transform": "^1.12.0",
"prosemirror-view": "^1.41.9"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/react": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.30.3.tgz",
"integrity": "sha512-gyK8UXFQlm2XgXEynNz5SzsxnTKtdBUp/PKeTeqFrk/m7L8p1eV+gcdAK+arTX+slubXRwLUm1lXrAQXRuiaTw==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"fast-equals": "^5.3.3",
"use-sync-external-store": "^1.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"optionalDependencies": {
"@tiptap/extension-bubble-menu": "^3.30.3",
"@tiptap/extension-floating-menu": "^3.30.3"
},
"peerDependencies": {
"@tiptap/core": "3.30.3",
"@tiptap/pm": "3.30.3",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.30.3",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.30.3.tgz",
"integrity": "sha512-hj4rAhAoQm+wRk8eLNTyK+wK81+epbuo4O9PvPRv7GJGwYycej+xUN7jGM7Tney7GuTZpHoDCuwLKD2n1oG+pg==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "3.30.3",
"@tiptap/extension-blockquote": "3.30.3",
"@tiptap/extension-bold": "3.30.3",
"@tiptap/extension-bullet-list": "3.30.3",
"@tiptap/extension-code": "3.30.3",
"@tiptap/extension-code-block": "3.30.3",
"@tiptap/extension-document": "3.30.3",
"@tiptap/extension-dropcursor": "3.30.3",
"@tiptap/extension-gapcursor": "3.30.3",
"@tiptap/extension-hard-break": "3.30.3",
"@tiptap/extension-heading": "3.30.3",
"@tiptap/extension-horizontal-rule": "3.30.3",
"@tiptap/extension-italic": "3.30.3",
"@tiptap/extension-link": "3.30.3",
"@tiptap/extension-list": "3.30.3",
"@tiptap/extension-list-item": "3.30.3",
"@tiptap/extension-list-keymap": "3.30.3",
"@tiptap/extension-ordered-list": "3.30.3",
"@tiptap/extension-paragraph": "3.30.3",
"@tiptap/extension-strike": "3.30.3",
"@tiptap/extension-text": "3.30.3",
"@tiptap/extension-underline": "3.30.3",
"@tiptap/extensions": "3.30.3",
"@tiptap/pm": "3.30.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@trickfilm400/rollup-plugin-off-main-thread": {
"version": "3.0.0-pre1",
"resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz",
@ -2644,6 +3127,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz",
"integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@types/resolve": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@ -2658,6 +3161,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@ -2698,7 +3207,6 @@
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -2890,7 +3398,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.11.12",
"caniuse-lite": "^1.0.30001809",
@ -3049,6 +3556,13 @@
"node": ">=8"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT",
"peer": true
},
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@ -3449,6 +3963,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz",
"integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@ -4426,6 +4949,12 @@
"node": ">=6"
}
},
"node_modules/linkifyjs": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
"license": "MIT"
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@ -4474,6 +5003,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "18.0.10",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.10.tgz",
"integrity": "sha512-FJeH4bRpYoXiggcgriCGItKCSv3xkngJc4QCZ/rkQCogU3VYaLxYJoZl8Nw/b4+x7iij/pd+09mZ6A1dXzpL0A==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -4590,6 +5131,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/own-keys": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz",
@ -4673,7 +5220,6 @@
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -4733,12 +5279,150 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/prosemirror-changeset": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.2.tgz",
"integrity": "sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz",
"integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz",
"integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.11",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz",
"integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.42.2",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.2.tgz",
"integrity": "sha512-Pdg0l5kXm8aLDquFAnQFTCITg0q44sLqBlHlpsVLD9segdOao8TOfQdAhCrCXyVgPSRr6UDDROOIWA3bIrN9YQ==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.8",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@ -4751,7 +5435,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@ -4982,6 +5665,12 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/safe-array-concat": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz",
@ -5453,7 +6142,6 @@
"integrity": "sha512-CN9BVxWhgS/hRxtUMjtC2uRWSTcSfQFHMDWma6sKKfIivCD91sM+FOPfvwoaRMqCSrUpe1nv3jDamd9eEQ4y+w==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0",
@ -5484,6 +6172,19 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/turndown": {
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
"integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==",
"license": "MIT",
"dependencies": {
"@mixmark-io/domino": "^2.2.0"
},
"engines": {
"node": ">=18",
"npm": ">=9"
}
},
"node_modules/type-fest": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
@ -5703,13 +6404,21 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@ -5795,6 +6504,12 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -9,10 +9,17 @@
"preview": "vite preview"
},
"dependencies": {
"@tiptap/extension-placeholder": "^3.30.3",
"@tiptap/extension-underline": "^3.30.3",
"@tiptap/pm": "^3.30.3",
"@tiptap/react": "^3.30.3",
"@tiptap/starter-kit": "^3.30.3",
"lucide-react": "^0.383.0",
"marked": "^18.0.10",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1"
"react-router-dom": "^6.23.1",
"turndown": "^7.2.4"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.0",

View File

@ -5,8 +5,12 @@ import { getMainNavItems } from './config/appNav.js'
import LoginScreen from './pages/LoginScreen.jsx'
import SetupScreen from './pages/SetupScreen.jsx'
import HomePage from './pages/HomePage.jsx'
import PlaceholderPage from './pages/PlaceholderPage.jsx'
import DialoguePage from './pages/DialoguePage.jsx'
import JournalSpacesPage from './pages/JournalSpacesPage.jsx'
import JournalSpacePage from './pages/JournalSpacePage.jsx'
import JournalDayPage from './pages/JournalDayPage.jsx'
import JournalEditorPage from './pages/JournalEditorPage.jsx'
import JournalSourcePage from './pages/JournalSourcePage.jsx'
import SettingsPage from './pages/SettingsPage.jsx'
import AdminHomePage from './pages/AdminHomePage.jsx'
import AdminUsersPage from './pages/AdminUsersPage.jsx'
@ -14,11 +18,14 @@ import AdminPromptsPage from './pages/AdminPromptsPage.jsx'
import AdminPlaceholdersPage from './pages/AdminPlaceholdersPage.jsx'
import AdminFeaturesPage from './pages/AdminFeaturesPage.jsx'
import AdminDialoguePage from './pages/AdminDialoguePage.jsx'
import AdminProvidersPage from './pages/AdminProvidersPage.jsx'
import RequireAdmin from './layouts/RequireAdmin.jsx'
import AdminShell from './layouts/AdminShell.jsx'
import { UnsavedProvider, useUnsavedChanges } from './context/UnsavedChanges.jsx'
function navItemActive(pathname, item, routerIsActive) {
if (item.to.startsWith('/admin')) return pathname.startsWith('/admin')
if (item.to === '/journal') return pathname.startsWith('/journal')
return routerIsActive
}
@ -83,13 +90,18 @@ function DesktopSidebar({ isAdmin, onLogout, name }) {
function AppShell() {
const { session, isAdmin, logout } = useAuth()
const { confirmLeave } = useUnsavedChanges()
const onLogout = () => {
if (!confirmLeave()) return
logout()
}
return (
<div className="app-shell">
<DesktopSidebar isAdmin={isAdmin} onLogout={logout} name={session?.profile?.name} />
<DesktopSidebar isAdmin={isAdmin} onLogout={onLogout} name={session?.profile?.name} />
<div className="app-main">
<header className="mobile-header">
<strong>Kanshō</strong>
<button type="button" className="ghost" onClick={logout}>Abmelden</button>
<button type="button" className="ghost" onClick={onLogout}>Abmelden</button>
</header>
<main className="page">
<Outlet />
@ -114,27 +126,31 @@ export default function App() {
}
return (
<Routes>
<Route element={<AppShell />}>
<Route path="/" element={<HomePage />} />
<Route path="/dialog" element={<DialoguePage />} />
<Route
path="/journal"
element={<PlaceholderPage title="Journal" note="Journaling ist Werkzeug, nicht Produktkern. Die Inhalte folgen dem Fachkonzept." />}
/>
<Route path="/settings" element={<SettingsPage />} />
<Route element={<RequireAdmin />}>
<Route path="/admin" element={<AdminShell />}>
<Route index element={<AdminHomePage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="prompts" element={<AdminPromptsPage />} />
<Route path="placeholders" element={<AdminPlaceholdersPage />} />
<Route path="features" element={<AdminFeaturesPage />} />
<Route path="dialogue" element={<AdminDialoguePage />} />
<UnsavedProvider>
<Routes>
<Route element={<AppShell />}>
<Route path="/" element={<HomePage />} />
<Route path="/journal" element={<JournalSpacesPage />} />
<Route path="/journal/:spaceId" element={<JournalSpacePage />} />
<Route path="/journal/:spaceId/:dayId" element={<JournalDayPage />} />
<Route path="/journal/:spaceId/:dayId/entry" element={<JournalEditorPage />} />
<Route path="/journal/:spaceId/:dayId/source/:conversationId" element={<JournalSourcePage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route element={<RequireAdmin />}>
<Route path="/dialog" element={<DialoguePage />} />
<Route path="/admin" element={<AdminShell />}>
<Route index element={<AdminHomePage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="prompts" element={<AdminPromptsPage />} />
<Route path="placeholders" element={<AdminPlaceholdersPage />} />
<Route path="features" element={<AdminFeaturesPage />} />
<Route path="providers" element={<AdminProvidersPage />} />
<Route path="dialogue" element={<AdminDialoguePage />} />
</Route>
</Route>
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</UnsavedProvider>
)
}

View File

@ -18,3 +18,26 @@ export async function api(path, { token, method = 'GET', body } = {}) {
}
return data
}
export async function apiUpload(path, { token, file, field = 'file' } = {}) {
const headers = {}
if (token) headers['X-Auth-Token'] = token
const body = new FormData()
body.append(field, file)
const response = await fetch(path, { method: 'POST', headers, body })
const data = await response.json().catch(() => null)
if (!response.ok) {
const detail = data?.detail
const message = typeof detail === 'string' ? detail : (detail?.message || 'Upload fehlgeschlagen')
throw new Error(message)
}
return data
}
export async function apiBlob(path, { token } = {}) {
const headers = {}
if (token) headers['X-Auth-Token'] = token
const response = await fetch(path, { headers })
if (!response.ok) throw new Error('Medium nicht lesbar')
return URL.createObjectURL(await response.blob())
}

View File

@ -8,43 +8,63 @@
--danger: #9b3d3d;
--nav-h: 64px;
--sidebar-w: 220px;
--content-w: 42rem;
--wide-w: 72rem;
--safe-top: env(safe-area-inset-top, 0px);
--safe-right: env(safe-area-inset-right, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
}
* { box-sizing: border-box; }
html, body, #root { margin: 0; min-height: 100%; }
html, body, #root {
margin: 0;
min-height: 100%;
max-width: 100%;
}
html { -webkit-text-size-adjust: 100%; }
body {
font-family: "Segoe UI", system-ui, sans-serif;
background: var(--bg);
color: var(--ink);
overflow-wrap: break-word;
overflow-x: hidden;
}
h1 { font-size: 1.4rem; margin: 0 0 0.6rem; }
h1 { font-size: 1.25rem; margin: 0 0 0.5rem; }
h2 { font-size: 1.05rem; margin: 1.1rem 0 0.45rem; }
p { line-height: 1.5; }
.muted { color: var(--muted); }
.muted, .lede { color: var(--muted); }
.lede { font-size: 0.9rem; }
.error { color: var(--danger); }
.centered { min-height: 100vh; display: grid; place-items: center; }
.centered { min-height: 100dvh; display: grid; place-items: center; }
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 16px;
padding: 1.25rem;
padding: 1rem;
min-width: 0;
max-width: 100%;
}
.auth-screen {
min-height: 100vh;
min-height: 100dvh;
display: grid;
place-items: center;
padding: 1.5rem;
padding: max(1rem, var(--safe-top)) max(1rem, var(--safe-right)) max(1rem, var(--safe-bottom)) max(1rem, var(--safe-left));
}
.auth-screen .card { width: min(420px, 100%); display: grid; gap: 0.8rem; }
label { display: grid; gap: 0.35rem; font-size: 0.9rem; }
input {
label.check { display: flex; align-items: center; gap: 0.5rem; }
input, textarea, select {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.7rem 0.8rem;
font: inherit;
width: 100%;
max-width: 100%;
}
button {
background: var(--accent);
@ -54,6 +74,7 @@ button {
padding: 0.75rem 1rem;
font: inherit;
cursor: pointer;
min-height: 2.75rem;
}
button.ghost {
background: transparent;
@ -62,29 +83,46 @@ button.ghost {
}
button:disabled { opacity: 0.6; }
.app-shell { min-height: 100vh; }
.app-main { min-height: 100vh; display: flex; flex-direction: column; }
.app-shell { min-height: 100dvh; width: 100%; max-width: 100%; }
.app-main {
min-height: 100dvh;
min-width: 0;
max-width: 100%;
display: flex;
flex-direction: column;
}
.mobile-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.8rem 1rem;
gap: 0.75rem;
padding: max(0.7rem, var(--safe-top)) max(1rem, var(--safe-right)) 0.7rem max(1rem, var(--safe-left));
border-bottom: 1px solid var(--line);
background: var(--card);
}
.page {
flex: 1;
padding: 1rem 1rem calc(var(--nav-h) + 1rem);
min-width: 0;
max-width: 100%;
display: flex;
flex-direction: column;
padding: 0.85rem max(0.85rem, var(--safe-right)) calc(var(--nav-h) + var(--safe-bottom) + 0.85rem) max(0.85rem, var(--safe-left));
}
.bottom-nav {
position: fixed;
left: 0; right: 0; bottom: 0;
height: var(--nav-h);
left: 0;
right: 0;
bottom: 0;
z-index: 20;
height: calc(var(--nav-h) + var(--safe-bottom));
padding-bottom: var(--safe-bottom);
display: flex;
background: var(--card);
border-top: 1px solid var(--line);
}
.nav-item {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
@ -92,21 +130,20 @@ button:disabled { opacity: 0.6; }
gap: 0.15rem;
color: var(--muted);
text-decoration: none;
font-size: 0.72rem;
font-size: 0.68rem;
}
.nav-item span {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-item.active { color: var(--accent); }
.desktop-sidebar { display: none; }
.meta { display: grid; grid-template-columns: 8rem 1fr; gap: 0.4rem 1rem; }
.meta dt { color: var(--muted); }
.meta dd { margin: 0; }
h2 { font-size: 1.1rem; margin: 1.2rem 0 0.5rem; }
textarea, select {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.7rem 0.8rem;
font: inherit;
width: 100%;
}
.meta { display: grid; grid-template-columns: 1fr; gap: 0.15rem 1rem; }
.meta dt { color: var(--muted); font-size: 0.85rem; }
.meta dd { margin: 0 0 0.55rem; }
.stack { display: grid; gap: 0.7rem; margin-top: 1rem; }
.admin-subnav {
display: flex;
@ -119,31 +156,305 @@ textarea, select {
color: var(--muted);
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.3rem 0.75rem;
font-size: 0.9rem;
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
}
.admin-link.active { color: var(--accent); border-color: var(--accent); }
table.plain { width: 100%; border-collapse: collapse; font-size: 0.95rem; }
table.plain th, table.plain td { text-align: left; padding: 0.45rem 0.3rem; border-bottom: 1px solid var(--line); }
.row-item { display: flex; justify-content: space-between; gap: 0.8rem; align-items: center; list-style: none; }
.row-actions { display: flex; gap: 0.3rem; }
ul.stack { padding: 0; }
.dialogue-layout { display: grid; gap: 1rem; margin-top: 1rem; }
@media (min-width: 720px) {
.dialogue-layout { grid-template-columns: 14rem 1fr; }
.table-scroll {
overflow-x: auto;
max-width: 100%;
-webkit-overflow-scrolling: touch;
}
.message-list { max-height: 40vh; overflow: auto; margin-bottom: 0.8rem; }
table.plain { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
table.plain th, table.plain td { text-align: left; padding: 0.45rem 0.3rem; border-bottom: 1px solid var(--line); white-space: nowrap; }
.row-item {
display: flex;
justify-content: space-between;
gap: 0.6rem;
align-items: flex-start;
flex-wrap: wrap;
list-style: none;
}
.row-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; }
.row-actions > button,
.row-actions > a {
flex: 1 1 8rem;
min-height: 2.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
ul.stack { padding: 0; }
.dialogue-page {
width: 100%;
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.dialogue-head { display: grid; gap: 0.45rem; }
.dialogue-crumb { margin: 0; font-size: 0.9rem; color: var(--muted); }
.dialogue-toolbar {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.55rem 0.8rem;
align-items: center;
}
.dialogue-switcher {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
min-width: 0;
}
.dialogue-switcher button {
width: auto;
min-height: 2.2rem;
font-size: 0.85rem;
}
.dialogue-tools { flex: 0 0 auto; }
.dialogue-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin: 0;
}
.generate-choice {
display: grid;
gap: 0.45rem;
padding: 0.7rem 0.8rem;
border: 1px solid var(--line);
border-radius: 12px;
background: #f3efe6;
}
.generate-choice p { margin: 0; }
.dialogue-layout {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 0.65rem;
min-width: 0;
width: 100%;
min-height: min(28rem, calc(100dvh - var(--nav-h) - 9rem));
flex: 1;
}
.dialogue-thread {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
max-width: 100%;
}
.message-list {
flex: 1;
min-height: 10rem;
overflow: auto;
overflow-x: hidden;
margin-bottom: 0.7rem;
-webkit-overflow-scrolling: touch;
}
.message-list .msg { margin: 0 0 0.75rem; overflow-wrap: anywhere; }
.message-list .msg.assistant {
border-left: 3px solid var(--accent);
padding-left: 0.65rem;
}
.dialogue-composer {
display: grid;
gap: 0.55rem;
margin-top: auto;
}
.dialogue-composer textarea { min-height: 4.5rem; resize: vertical; }
.dialogue-composer button { width: 100%; }
.dialogue-after { margin-top: 1rem; }
.writing-brief {
max-height: 40vh;
overflow: auto;
overflow-wrap: anywhere;
}
.writing-review-prompt {
font-family: ui-monospace, Consolas, monospace;
font-size: 0.8rem;
white-space: pre-wrap;
max-height: 16rem;
overflow: auto;
}
.facet-list { list-style: none; padding: 0; }
.facet-row {
display: grid;
gap: 0.4rem;
padding: 0.7rem 0;
border-top: 1px solid var(--line);
}
.facet-head {
display: flex;
justify-content: space-between;
gap: 0.8rem;
align-items: baseline;
}
label.inline {
display: flex;
gap: 0.45rem;
align-items: center;
font-size: 0.92rem;
}
label.button-file {
display: inline-flex;
align-items: center;
background: transparent;
color: var(--ink);
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.4rem 0.6rem;
min-height: 2.75rem;
cursor: pointer;
}
.trace-panel {
margin-top: 1.2rem;
padding-top: 0.8rem;
border-top: 1px dashed var(--line);
min-width: 0;
max-width: 100%;
}
.trace-panel ol { padding-left: 1.2rem; margin: 0.4rem 0 0.8rem; }
.trace-panel details { margin: 0.5rem 0; }
.trace-panel summary { cursor: pointer; color: var(--muted); }
button.ghost.active { color: var(--accent); }
.media-row { display: flex; flex-wrap: wrap; gap: 0.8rem; }
.media-row figure { margin: 0; width: 7.5rem; }
.media-row img { width: 100%; border-radius: 8px; border: 1px solid var(--line); }
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.journal-doc { display: grid; gap: 0.7rem; }
.journal-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
position: sticky;
top: 0;
z-index: 2;
background: var(--card);
padding: 0.2rem 0;
}
.journal-toolbar button { width: auto; min-height: 2.2rem; font-size: 0.85rem; }
.journal-flow {
border: 1px solid var(--line);
border-radius: 12px;
background: #fffcf7;
min-height: 22rem;
padding: 0.85rem 1rem;
}
.journal-flow.disabled { opacity: 0.7; }
.journal-flow .tiptap,
.journal-flow .ProseMirror {
min-height: 20rem;
outline: none;
line-height: 1.65;
font: inherit;
}
.journal-flow .tiptap p { margin: 0 0 0.85rem; }
.journal-flow .tiptap h2 {
font-size: 1.2rem;
margin: 0 0 0.7rem;
font-weight: 650;
}
.journal-flow .tiptap h3 {
font-size: 1.05rem;
margin: 0 0 0.65rem;
font-weight: 650;
}
.journal-flow .tiptap ul,
.journal-flow .tiptap ol { margin: 0 0 0.85rem; padding-left: 1.3rem; }
.journal-flow .tiptap li { margin: 0 0 0.25rem; }
.journal-flow .tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
height: 0;
color: var(--muted);
pointer-events: none;
}
.journal-figure {
margin: 0.4rem 0 1rem;
display: grid;
gap: 0.45rem;
cursor: grab;
}
.journal-figure.selected { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 12px; }
.journal-figure img,
.journal-figure video {
width: 100%;
max-height: 28rem;
object-fit: contain;
background: #f3efe6;
border: 1px solid var(--line);
border-radius: 12px;
}
.journal-unplaced {
padding: 0.7rem 0.8rem;
border: 1px dashed var(--line);
border-radius: 12px;
}
.day-scratch {
display: grid;
gap: 0.45rem;
align-content: start;
padding: 0.8rem;
border: 1px solid var(--line);
border-radius: 12px;
background: #f8f5ee;
}
.day-scratch h2 { margin: 0; }
.day-scratch-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
.day-scratch-list .check { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 0.45rem; }
.day-scratch-list .check input[type="checkbox"] { width: auto; }
.day-scratch-list .check input[type="text"],
.day-scratch-list .check input:not([type="checkbox"]) { min-width: 0; }
a { color: var(--accent); }
pre.code {
max-width: 100%;
overflow-x: auto;
overflow-wrap: anywhere;
white-space: pre-wrap;
padding: 0.8rem;
border: 1px solid var(--line);
border-radius: 10px;
background: #f3efe6;
font-size: 0.85rem;
}
@media (min-width: 1024px) {
.app-shell { display: grid; grid-template-columns: var(--sidebar-w) 1fr; }
h1 { font-size: 1.4rem; }
h2 { font-size: 1.1rem; }
.lede { font-size: 1rem; }
.card { padding: 1.25rem; }
button { min-height: 0; }
.app-shell {
display: grid;
grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
}
.desktop-sidebar {
display: flex;
flex-direction: column;
border-right: 1px solid var(--line);
background: var(--card);
padding: 1.2rem 0.8rem;
min-height: 100vh;
min-height: 100dvh;
position: sticky;
top: 0;
}
.desktop-sidebar .brand { font-weight: 700; padding: 0 0.6rem 1rem; }
.desktop-sidebar nav { display: grid; gap: 0.2rem; }
@ -158,5 +469,31 @@ button.ghost.active { color: var(--accent); }
.desktop-sidebar .nav-item.active { background: #eef3ef; }
.sidebar-footer { margin-top: auto; display: grid; gap: 0.4rem; padding: 0.6rem; }
.mobile-header, .bottom-nav { display: none; }
.page { padding: 1.5rem; }
.page {
padding: 1.5rem;
align-items: stretch;
}
.page > .card { width: min(var(--content-w), 100%); }
.page > .dialogue-page,
.admin-realm .card { width: min(var(--wide-w), 100%); }
.meta { grid-template-columns: 8rem minmax(0, 1fr); }
.meta dd { margin: 0; }
.row-actions > button,
.row-actions > a {
flex: 0 0 auto;
min-height: 0;
}
.dialogue-page { min-height: calc(100dvh - 3rem); }
.dialogue-layout { min-height: calc(100dvh - 10rem); }
.dialogue-layout.has-scratch {
display: grid;
grid-template-columns: minmax(0, 1fr) 17rem;
align-items: stretch;
}
.page > .journal-editor-page { width: min(var(--content-w), 100%); }
.dialogue-composer textarea { min-height: 7rem; }
.dialogue-composer button { width: auto; justify-self: start; }
.writing-brief { max-height: none; }
}

View File

@ -0,0 +1,81 @@
function Block({ title, value }) {
if (value == null || value === '') return null
return (
<details open>
<summary>{title}</summary>
<pre className="code">{typeof value === 'string' ? value : JSON.stringify(value, null, 2)}</pre>
</details>
)
}
function layerLabel(trace) {
if (trace?.layer === 'journalentwurf') return 'Journalentwurf'
if (trace?.purpose === 'journal_generate') return 'Journalentwurf'
return 'Dialogzug'
}
function detectLabel(trace) {
if (trace?.detect_note === 'remote_llm') {
return `${trace.detect_provider || 'openrouter'} (Detect-Call, Testphase)`
}
if (trace?.detect_note === 'local_llm') {
return `${trace.detect_provider || 'lokal'} (Detect-Call)`
}
if (trace?.detect_provider === 'pattern' || !trace?.detect_provider) {
return 'Muster, kein KI-Call'
}
return trace.detect_provider
}
export default function CallTrace({ decision, trace }) {
if (!decision && !trace) return null
const layer = layerLabel(trace)
return (
<aside className="trace-panel">
<h2>Testspur</h2>
<ol className="muted">
<li>
Maskierung sucht nur Namen (Testphase: eigener OpenRouter-Call auf user-Zeilen).
Sie wählt keine Operation und schreibt keinen Impuls.
</li>
<li>
{layer} ist ein Sprachmodell-Call. Intern ist die lokale Klartext-Vorlage
(nicht gesendet). Egress ist dieselbe Vorlage, maskiert das ist der gesendete Call.
Operation und sichtbare Antwort kommen beide aus diesem einen Call.
</li>
<li>Es gibt keinen dritten Call, der danach noch einmal antwortet.</li>
</ol>
{decision && (
<p>
Operation im Dialogzug: <strong>{decision.label}</strong>
{decision.operation ? ` (${decision.operation})` : ''}
{!decision.parsed && !decision.guard ? ' — vom Modell nicht als Operation geliefert' : ''}
{decision.guard ? ` — lokal ersetzt (${decision.guard})` : ''}
</p>
)}
{trace && (
<>
<dl className="meta">
<dt>Schicht</dt>
<dd>{layer}</dd>
<dt>Prompt</dt>
<dd>{trace.prompt_slug || '—'}</dd>
<dt>Sprachmodell</dt>
<dd>{[trace.provider, trace.model].filter(Boolean).join(' · ') || '—'}</dd>
<dt>Maskierung</dt>
<dd>{detectLabel(trace)}</dd>
<dt>Klasse</dt>
<dd>{trace.data_class || '—'}</dd>
<dt>Masken</dt>
<dd>{trace.mapping_count ?? 0}</dd>
</dl>
<Block title="1. Maskierung, Eingabe (nur user-Zeilen)" value={trace.mask_input} />
<Block title="2a. Dialogzug intern (Klartext-Vorlage, nicht gesendet)" value={trace.intern} />
<Block title="2b. Dialogzug Egress (Sprachmodell-Call für Operation und Impuls)" value={trace.egress} />
<Block title="Antwort dieses einen Calls (Operation + Impuls)" value={trace.raw} />
<Block title="Lokal demaskiert" value={trace.reply} />
</>
)}
</aside>
)
}

View File

@ -0,0 +1,115 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { api } from '../api.js'
function newItem() {
const id = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `s-${Date.now()}-${Math.random()}`
return { id, text: '', done: false }
}
function rowsFrom(items) {
if (items?.length) {
return items.map((item) => ({
id: item.id || newItem().id,
text: item.text || '',
done: !!item.done
}))
}
return [newItem()]
}
export default function DayScratch({ dayId, token, items, onError }) {
const [local, setLocal] = useState(() => rowsFrom(items))
const timer = useRef(null)
const seq = useRef(0)
const pendingFocus = useRef(null)
const inputRefs = useRef({})
useEffect(() => {
window.clearTimeout(timer.current)
seq.current += 1
setLocal(rowsFrom(items))
}, [dayId])
useLayoutEffect(() => {
const id = pendingFocus.current
if (!id) return
const el = inputRefs.current[id]
if (!el) return
el.focus()
pendingFocus.current = null
}, [local])
const persist = (next, focusId) => {
if (focusId) pendingFocus.current = focusId
const mine = ++seq.current
setLocal(next)
window.clearTimeout(timer.current)
timer.current = window.setTimeout(async () => {
try {
await api(`/api/journal/days/${dayId}/scratch`, {
token,
method: 'PATCH',
body: { items: next.filter((item) => item.text.trim() || item.done) }
})
} catch (err) {
if (mine === seq.current) onError?.(err.message)
}
}, 400)
}
useEffect(() => () => window.clearTimeout(timer.current), [])
const patch = (id, fields) => persist(local.map((row) => (row.id === id ? { ...row, ...fields } : row)))
return (
<aside className="day-scratch">
<h2>Stichpunkte</h2>
<p className="muted">
Nur für dich, parallel zum Gespräch. Kanshō sieht diese Liste nicht noch unklar, ob das später sinnvoll wäre.
</p>
<ul className="day-scratch-list">
{local.map((item, index) => (
<li key={item.id}>
<label className="check">
<input
type="checkbox"
checked={!!item.done}
onChange={(e) => patch(item.id, { done: e.target.checked })}
/>
<input
ref={(el) => {
if (el) inputRefs.current[item.id] = el
else delete inputRefs.current[item.id]
}}
type="text"
autoComplete="off"
value={item.text}
placeholder="Nicht vergessen …"
onChange={(e) => patch(item.id, { text: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
const created = newItem()
const next = [...local]
next.splice(index + 1, 0, created)
persist(next, created.id)
}
}}
/>
</label>
</li>
))}
</ul>
<button
type="button"
className="ghost"
onClick={() => {
const created = newItem()
persist([...local, created], created.id)
}}
>
Punkt hinzufügen
</button>
</aside>
)
}

View File

@ -0,0 +1,223 @@
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extension-placeholder'
import { docToMarkdown, markdownToDoc, unplacedMedia } from '../journal/document.js'
import { JournalMediaContext, KanshoMedia } from '../journal/KanshoMedia.jsx'
function ToolbarButton({ active, children, disabled, onClick }) {
return (
<button type="button" className={active ? 'ghost active' : 'ghost'} disabled={disabled} onClick={onClick}>
{children}
</button>
)
}
const JournalDocumentEditor = forwardRef(function JournalDocumentEditor(
{ body, contentKey, onChange, media, previews, onUpload, onPlaced, onRemoveMedia, disabled },
ref
) {
const imageInput = useRef(null)
const videoInput = useRef(null)
const editorRef = useRef(null)
const editor = useEditor({
immediatelyRender: false,
shouldRerenderOnTransaction: true,
editable: !disabled,
extensions: [
StarterKit.configure({
code: false,
codeBlock: false,
blockquote: false,
link: false,
horizontalRule: false,
heading: { levels: [2, 3] }
}),
Placeholder.configure({ placeholder: 'Schreiben …' }),
KanshoMedia
],
content: '',
onUpdate: ({ editor: instance }) => {
onChange(docToMarkdown(instance.getJSON()))
}
})
editorRef.current = editor
useImperativeHandle(ref, () => ({
getMarkdown: () => {
const instance = editorRef.current
if (!instance) return body || ''
return docToMarkdown(instance.getJSON())
}
}), [body])
useEffect(() => {
if (!editor) return
editor.setEditable(!disabled)
}, [editor, disabled])
useEffect(() => {
if (!editor) return
editor.commands.setContent(markdownToDoc(body || ''), { emitUpdate: false })
}, [editor, contentKey])
const marks = useEditorState({
editor,
selector: ({ editor: instance }) =>
instance
? {
paragraph: instance.isActive('paragraph'),
heading: instance.isActive('heading', { level: 2 }),
subheading: instance.isActive('heading', { level: 3 }),
bold: instance.isActive('bold'),
italic: instance.isActive('italic'),
underline: instance.isActive('underline'),
bullet: instance.isActive('bulletList'),
ordered: instance.isActive('orderedList')
}
: {}
})
const insertFile = async (event) => {
const file = event.target.files?.[0]
event.target.value = ''
if (!file || !editor) return
const asset = await onUpload(file)
if (!asset?.id) return
editor.chain().focus().insertKanshoMedia({
id: asset.id,
caption: '',
kind: asset.kind || (file.type.startsWith('video/') ? 'video' : 'image')
}).run()
await onPlaced?.()
}
const insertExisting = async (item) => {
if (!editor) return
editor.chain().focus().insertKanshoMedia({
id: item.id,
caption: '',
kind: item.kind || 'image'
}).run()
await onPlaced?.()
}
const unused = unplacedMedia(editor ? docToMarkdown(editor.getJSON()) : body, media)
return (
<JournalMediaContext.Provider value={{ previews, media, onRemoveMedia }}>
<div className="journal-doc">
<div className="journal-toolbar" role="toolbar" aria-label="Textformat">
<ToolbarButton
disabled={disabled || !editor}
active={marks?.paragraph}
onClick={() => editor.chain().focus().setParagraph().run()}
>
Absatz
</ToolbarButton>
<ToolbarButton
disabled={disabled || !editor}
active={marks?.heading}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
Überschrift
</ToolbarButton>
<ToolbarButton
disabled={disabled || !editor}
active={marks?.subheading}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
>
Zwischenüberschrift
</ToolbarButton>
<ToolbarButton
disabled={disabled || !editor}
active={marks?.bold}
onClick={() => editor.chain().focus().toggleBold().run()}
>
Fett
</ToolbarButton>
<ToolbarButton
disabled={disabled || !editor}
active={marks?.italic}
onClick={() => editor.chain().focus().toggleItalic().run()}
>
Kursiv
</ToolbarButton>
<ToolbarButton
disabled={disabled || !editor}
active={marks?.underline}
onClick={() => editor.chain().focus().toggleUnderline().run()}
>
Unterstrichen
</ToolbarButton>
<ToolbarButton
disabled={disabled || !editor}
active={marks?.bullet}
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
Liste
</ToolbarButton>
<ToolbarButton
disabled={disabled || !editor}
active={marks?.ordered}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
Nummerierung
</ToolbarButton>
<ToolbarButton disabled={disabled || !editor} onClick={() => imageInput.current?.click()}>
Bild
</ToolbarButton>
<ToolbarButton disabled={disabled || !editor} onClick={() => videoInput.current?.click()}>
Video
</ToolbarButton>
<input
ref={imageInput}
className="visually-hidden"
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={insertFile}
/>
<input
ref={videoInput}
className="visually-hidden"
type="file"
accept="video/mp4,video/webm,video/quicktime"
onChange={insertFile}
/>
</div>
<div className={`journal-flow ${disabled ? 'disabled' : ''}`}>
<EditorContent editor={editor} />
</div>
{unused.length > 0 && (
<div className="journal-unplaced">
<p className="muted">Hochgeladen, aber nicht im Text. Dieselbe Datei mehrfach zu wählen erzeugt mehrere Uploads im Text duplizieren, statt erneut hochzuladen.</p>
<ul className="stack">
{unused.map((item) => (
<li key={item.id} className="row-item">
<span>{item.filename || item.kind} · {String(item.id).slice(0, 8)}</span>
<span className="row-actions">
<button type="button" className="ghost" disabled={disabled} onClick={() => insertExisting(item)}>
Hier einfügen
</button>
<button
type="button"
className="ghost"
disabled={disabled}
onClick={() => onRemoveMedia?.(item.id)}
>
Verwerfen
</button>
</span>
</li>
))}
</ul>
</div>
)}
</div>
</JournalMediaContext.Provider>
)
})
export default JournalDocumentEditor

View File

@ -5,6 +5,7 @@ export function getAdminNavItems() {
{ to: '/admin/prompts', label: 'Prompts' },
{ to: '/admin/placeholders', label: 'Platzhalter' },
{ to: '/admin/features', label: 'Kontingente' },
{ to: '/admin/providers', label: 'Schnittstellen' },
{ to: '/admin/dialogue', label: 'Dialog' }
]
}

View File

@ -1,9 +1,8 @@
import { Home, MessageCircle, BookOpen, Settings, Shield } from 'lucide-react'
import { Home, BookOpen, Settings, Shield } from 'lucide-react'
export function getMainNavItems(isAdmin) {
const items = [
{ to: '/', label: 'Start', icon: Home },
{ to: '/dialog', label: 'Dialog', icon: MessageCircle },
{ to: '/journal', label: 'Journal', icon: BookOpen },
{ to: '/settings', label: 'Einstellungen', icon: Settings }
]

View File

@ -0,0 +1,72 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'
const UnsavedContext = createContext({
dirty: false,
setDirty: () => {},
confirmLeave: () => true
})
const LEAVE_MESSAGE = 'Ungespeicherte Änderungen gehen verloren. Trotzdem verlassen?'
export function UnsavedProvider({ children }) {
const [dirty, setDirty] = useState(false)
const dirtyRef = useRef(false)
dirtyRef.current = dirty
const location = useLocation()
const confirmLeave = useCallback(() => {
if (!dirtyRef.current) return true
const ok = window.confirm(LEAVE_MESSAGE)
if (ok) {
dirtyRef.current = false
setDirty(false)
}
return ok
}, [])
useEffect(() => {
const onBeforeUnload = (event) => {
if (!dirtyRef.current) return
event.preventDefault()
event.returnValue = ''
}
window.addEventListener('beforeunload', onBeforeUnload)
return () => window.removeEventListener('beforeunload', onBeforeUnload)
}, [])
useEffect(() => {
const onClick = (event) => {
if (!dirtyRef.current || event.defaultPrevented) return
const anchor = event.target.closest?.('a[href]')
if (!anchor) return
if (anchor.target === '_blank' || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
let url
try {
url = new URL(anchor.href, window.location.origin)
} catch {
return
}
if (url.origin !== window.location.origin) return
const next = `${url.pathname}${url.search}`
const here = `${location.pathname}${location.search}`
if (next === here) return
if (!confirmLeave()) {
event.preventDefault()
event.stopPropagation()
}
}
document.addEventListener('click', onClick, true)
return () => document.removeEventListener('click', onClick, true)
}, [confirmLeave, location.pathname, location.search])
return (
<UnsavedContext.Provider value={{ dirty, setDirty, confirmLeave }}>
{children}
</UnsavedContext.Provider>
)
}
export function useUnsavedChanges() {
return useContext(UnsavedContext)
}

View File

@ -0,0 +1,117 @@
import { Node, mergeAttributes } from '@tiptap/core'
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
import { createContext, useContext } from 'react'
export const JournalMediaContext = createContext({
previews: {},
media: [],
onRemoveMedia: null
})
function KanshoMediaView({ node, updateAttributes, deleteNode, selected }) {
const { previews, media, onRemoveMedia } = useContext(JournalMediaContext)
const id = node.attrs.id
const kind = media.find((item) => item.id === id)?.kind || node.attrs.kind || 'image'
const src = previews[id]
return (
<NodeViewWrapper
as="figure"
className={`journal-figure ${selected ? 'selected' : ''}`}
data-kansho-media={id}
data-caption={node.attrs.caption || ''}
data-kind={kind}
data-drag-handle
>
{src ? (
kind === 'video' ? (
<video src={src} controls playsInline />
) : (
<img src={src} alt={node.attrs.caption || 'Bild'} />
)
) : (
<p className="muted">Medium wird geladen oder fehlt.</p>
)}
<input
value={node.attrs.caption || ''}
placeholder="Unterschrift"
onChange={(event) => updateAttributes({ caption: event.target.value })}
onKeyDown={(event) => {
if (event.key === 'Enter') event.preventDefault()
}}
/>
<button
type="button"
className="ghost"
onClick={() => deleteNode()}
>
Aus dem Text nehmen
</button>
</NodeViewWrapper>
)
}
export const KanshoMedia = Node.create({
name: 'kanshoMedia',
group: 'block',
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return {
id: { default: null },
caption: { default: '' },
kind: { default: 'image' }
}
},
parseHTML() {
return [
{
tag: 'figure[data-kansho-media]',
getAttrs: (el) => ({
id: el.getAttribute('data-kansho-media'),
caption: el.getAttribute('data-caption') || '',
kind: el.getAttribute('data-kind') || 'image'
})
},
{
tag: 'img[src^="kansho-media:"]',
getAttrs: (el) => ({
id: (el.getAttribute('src') || '').slice('kansho-media:'.length),
caption: el.getAttribute('alt') || '',
kind: 'image'
})
}
]
},
renderHTML({ HTMLAttributes }) {
const id = HTMLAttributes.id
const caption = HTMLAttributes.caption || ''
const kind = HTMLAttributes.kind || 'image'
return [
'figure',
mergeAttributes({
'data-kansho-media': id,
'data-caption': caption,
'data-kind': kind
}),
['img', { src: `kansho-media:${id}`, alt: caption }]
]
},
addNodeView() {
return ReactNodeViewRenderer(KanshoMediaView)
},
addCommands() {
return {
insertKanshoMedia:
(attrs) =>
({ commands }) =>
commands.insertContent({ type: this.name, attrs })
}
}
})

View File

@ -0,0 +1,247 @@
import { lexer } from 'marked'
const UUID = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'
export const MD_MEDIA_RE = new RegExp(`!\\[([^\\]]*)\\]\\(kansho-media:(${UUID})\\)`, 'g')
export const LEGACY_MEDIA_RE = new RegExp(`\\[\\[media:(${UUID})(?:\\|([^\\]]*))?\\]\\]`, 'g')
const MEDIA_HREF_RE = new RegExp(`^kansho-media:(${UUID})$`)
export function mediaToken(id, caption = '') {
const cap = String(caption || '').replace(/]/g, '').replace(/\n/g, ' ').trim()
return `![${cap}](kansho-media:${id})`
}
export function toMarkdown(body) {
return String(body || '').replace(LEGACY_MEDIA_RE, (_, id, cap) => mediaToken(id, cap || ''))
}
export function mediaIds(body) {
const seen = []
const md = new RegExp(MD_MEDIA_RE.source, 'g')
let match
const normalized = toMarkdown(body)
while ((match = md.exec(normalized)) !== null) {
if (!seen.includes(match[2])) seen.push(match[2])
}
return seen
}
export function unplacedMedia(body, media) {
const placed = new Set(mediaIds(body))
return (media || []).filter((item) => item?.id && !placed.has(item.id))
}
function applyMark(nodes, type) {
return (nodes || []).map((node) => {
if (node.type !== 'text') return node
return { ...node, marks: [...(node.marks || []), { type }] }
})
}
function inlineFromTokens(tokens) {
const out = []
for (const token of tokens || []) {
if (token.type === 'text' || token.type === 'escape') {
if (token.text) out.push({ type: 'text', text: token.text })
} else if (token.type === 'strong') {
out.push(...applyMark(inlineFromTokens(token.tokens), 'bold'))
} else if (token.type === 'em') {
out.push(...applyMark(inlineFromTokens(token.tokens), 'italic'))
} else if (token.type === 'del') {
out.push(...applyMark(inlineFromTokens(token.tokens), 'strike'))
} else if (token.type === 'codespan') {
out.push({ type: 'text', text: token.text || '', marks: [{ type: 'code' }] })
} else if (token.type === 'br') {
out.push({ type: 'hardBreak' })
} else if (token.type === 'html' && /<\/?u>/i.test(token.text || '')) {
const inner = String(token.text || '').replace(/<\/?u>/gi, '')
if (inner) out.push({ type: 'text', text: inner, marks: [{ type: 'underline' }] })
} else if (token.tokens) {
out.push(...inlineFromTokens(token.tokens))
} else if (token.text) {
out.push({ type: 'text', text: token.text })
}
}
return out
}
function mediaNodeFromImage(token) {
const href = token?.href || ''
const found = href.match(MEDIA_HREF_RE)
if (!found) return null
return {
type: 'kanshoMedia',
attrs: { id: found[1], caption: token.text || '', kind: 'image' }
}
}
function blocksFromParagraphTokens(tokens) {
const blocks = []
let buffer = []
const flush = () => {
if (!buffer.length) return
blocks.push({ type: 'paragraph', content: inlineFromTokens(buffer) })
buffer = []
}
for (const token of tokens || []) {
const media = token.type === 'image' ? mediaNodeFromImage(token) : null
if (media) {
flush()
blocks.push(media)
} else {
buffer.push(token)
}
}
flush()
return blocks
}
function listItemContent(item) {
const tokens = item.tokens || []
const blocks = []
const paragraphish = []
for (const token of tokens) {
if (token.type === 'list') {
if (paragraphish.length) {
blocks.push(...blocksFromParagraphTokens(paragraphish))
paragraphish.length = 0
}
blocks.push(listNode(token))
} else if (token.type === 'paragraph' || token.type === 'text' || token.type === 'image') {
if (token.type === 'paragraph') paragraphish.push(...(token.tokens || []))
else paragraphish.push(token)
} else if (token.tokens) {
paragraphish.push(...token.tokens)
} else if (token.text) {
paragraphish.push({ type: 'text', text: token.text })
}
}
if (paragraphish.length) blocks.push(...blocksFromParagraphTokens(paragraphish))
return blocks.length ? blocks : [{ type: 'paragraph' }]
}
function listNode(token) {
return {
type: token.ordered ? 'orderedList' : 'bulletList',
content: (token.items || []).map((item) => ({
type: 'listItem',
content: listItemContent(item)
}))
}
}
export function markdownToDoc(md) {
const tokens = lexer(toMarkdown(md || ''))
const content = []
for (const token of tokens) {
if (token.type === 'space') continue
if (token.type === 'heading') {
const level = token.depth <= 1 ? 2 : Math.min(token.depth, 3)
content.push({
type: 'heading',
attrs: { level },
content: inlineFromTokens(token.tokens)
})
continue
}
if (token.type === 'list') {
content.push(listNode(token))
continue
}
if (token.type === 'paragraph') {
content.push(...blocksFromParagraphTokens(token.tokens || []))
}
}
return { type: 'doc', content: content.length ? content : [{ type: 'paragraph' }] }
}
function wrapMarks(text, marks) {
let out = text
const types = new Set((marks || []).map((mark) => mark.type))
if (types.has('code')) out = `\`${out}\``
if (types.has('bold')) out = `**${out}**`
if (types.has('italic')) out = `*${out}*`
if (types.has('strike')) out = `~~${out}~~`
if (types.has('underline')) out = `<u>${out}</u>`
return out
}
function inlineToMarkdown(nodes) {
return (nodes || [])
.map((node) => {
if (node.type === 'text') return wrapMarks(node.text || '', node.marks)
if (node.type === 'hardBreak') return '\n'
return inlineToMarkdown(node.content)
})
.join('')
}
function listItemMarkdown(item, bullet) {
const parts = []
for (const child of item.content || []) {
if (child.type === 'paragraph') parts.push(inlineToMarkdown(child.content))
else if (child.type === 'bulletList' || child.type === 'orderedList') {
parts.push(blockToMarkdown(child).split('\n').map((line) => (line ? ` ${line}` : line)).join('\n'))
} else {
const nested = blockToMarkdown(child)
if (nested) parts.push(nested)
}
}
const [first, ...rest] = parts.join('\n').split('\n')
return [`${bullet}${first || ''}`, ...rest.map((line) => (line ? ` ${line}` : ''))].join('\n')
}
function blockToMarkdown(node) {
if (!node) return ''
if (node.type === 'paragraph') return inlineToMarkdown(node.content)
if (node.type === 'heading') {
const level = Math.min(Math.max(node.attrs?.level || 2, 2), 3)
return `${'#'.repeat(level)} ${inlineToMarkdown(node.content)}`
}
if (node.type === 'bulletList') {
return (node.content || []).map((item) => listItemMarkdown(item, '- ')).join('\n')
}
if (node.type === 'orderedList') {
return (node.content || [])
.map((item, index) => listItemMarkdown(item, `${index + 1}. `))
.join('\n')
}
if (node.type === 'kanshoMedia') {
const id = node.attrs?.id
if (!id) return ''
return mediaToken(id, node.attrs?.caption || '')
}
if (node.content) {
return (node.content || []).map(blockToMarkdown).filter(Boolean).join('\n\n')
}
return inlineToMarkdown(node.content)
}
export function docToMarkdown(doc) {
return (doc?.content || []).map(blockToMarkdown).filter((block) => block !== '').join('\n\n').trim()
}
export function formatWhen(raw) {
if (!raw) return ''
const value = String(raw).trim()
const iso = /Z$|[+-]\d{2}(:?\d{2})?$/.test(value)
? value
: `${value.replace(' ', 'T')}Z`
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
}
export function entryTitle(raw) {
const line = String(raw || '').trim().split('\n')[0].trim()
return line.replace(/^#{1,6}\s+/, '').trim()
}
const ORIGIN_LABELS = {
accepted_draft: 'Übernommen',
user_edit: 'Bearbeitet',
restore: 'Wiederhergestellt'
}
export function originLabel(origin) {
return ORIGIN_LABELS[origin] || origin || 'Fassung'
}

View File

@ -0,0 +1,36 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { docToMarkdown, entryTitle, formatWhen, markdownToDoc, mediaIds, unplacedMedia } from './document.js'
const MEDIA_A = '0286aa2a-d557-427d-a72e-a2b4373b8e86'
const MEDIA_B = '03a2f6fc-2239-443d-8d23-21472edd87ff'
test('markdown media survives editor roundtrip', () => {
const md = `Heute am Hafen.\n\n![Kai](kansho-media:${MEDIA_A})\n\nDanach.`
const back = docToMarkdown(markdownToDoc(md))
assert.equal(back, md)
assert.deepEqual(mediaIds(back), [MEDIA_A])
})
test('unplaced media keeps unused uploads out of the unused list when referenced', () => {
const md = `![a](kansho-media:${MEDIA_A})`
const media = [
{ id: MEDIA_A, filename: 'kai.png' },
{ id: MEDIA_B, filename: 'kai.png' }
]
assert.deepEqual(unplacedMedia(md, media).map((item) => item.id), [MEDIA_B])
})
test('entryTitle strips markdown heading marks', () => {
assert.equal(entryTitle('# ein langer Tag'), 'ein langer Tag')
assert.equal(entryTitle('Ohne Raute'), 'Ohne Raute')
})
test('formatWhen treats sqlite utc as utc', () => {
const shown = formatWhen('2026-08-24 12:00:00')
const expected = new Date('2026-08-24T12:00:00Z').toLocaleString('de-DE', {
dateStyle: 'short',
timeStyle: 'short'
})
assert.equal(shown, expected)
})

View File

@ -58,7 +58,7 @@ export default function AdminDialoguePage() {
<pre className="code">{JSON.stringify(detail.derived_records, null, 2)}</pre>
</>
)}
<p className="muted"><Link to="/dialog">Zur dünnen Persistenz-Ansicht</Link></p>
<p className="muted"><Link to="/dialog">Zum Dev/Test-Harness (unzugeordnete Conversations)</Link></p>
</section>
)
}

View File

@ -28,6 +28,7 @@ export default function AdminFeaturesPage() {
{sub && (
<p>Aktueller Rahmen-Tier: <strong>{sub.tier?.name}</strong> ({sub.tier?.id}). Billing: {sub.billing ? 'aktiv' : 'keine Zahlungsanbindung'}.</p>
)}
<div className="table-scroll">
<table className="plain">
<thead>
<tr><th>Feature</th><th>Typ</th><th>Reset</th><th>Kategorie</th></tr>
@ -43,6 +44,7 @@ export default function AdminFeaturesPage() {
))}
</tbody>
</table>
</div>
</section>
)
}

View File

@ -30,8 +30,12 @@ export default function AdminHomePage() {
<dd>{health.inventory?.dialogue?.messages ?? 0}</dd>
<dt>Derived</dt>
<dd>{health.inventory?.dialogue?.derived_records ?? 0}</dd>
<dt>Sprachmodell</dt>
<dd>{health.inventory?.providers?.generate?.ready ? 'bereit' : 'nicht bereit'}</dd>
<dt>Maskierung</dt>
<dd>{health.inventory?.providers?.detect?.ready ? 'bereit (LLM)' : 'Muster'}</dd>
</dl>
<p className="muted">Weiter: <Link to="/admin/users">Nutzer</Link> · <Link to="/admin/prompts">Prompts</Link> · <Link to="/admin/placeholders">Platzhalter</Link> · <Link to="/admin/features">Kontingente</Link> · <Link to="/admin/dialogue">Dialog</Link></p>
<p className="muted">Weiter: <Link to="/admin/users">Nutzer</Link> · <Link to="/admin/prompts">Prompts</Link> · <Link to="/admin/placeholders">Platzhalter</Link> · <Link to="/admin/features">Kontingente</Link> · <Link to="/admin/providers">Schnittstellen</Link> · <Link to="/admin/dialogue">Dialog</Link></p>
</>
)}
</section>

View File

@ -3,6 +3,7 @@ import { api } from '../api.js'
import { useAuth } from '../context/AuthContext.jsx'
const emptyForm = {
id: '',
slug: '',
name: '',
description: '',
@ -10,7 +11,8 @@ const emptyForm = {
prompt_type: 'base',
template: '',
required_feature: 'ai_calls',
active: true
active: true,
is_system_default: false
}
export default function AdminPromptsPage() {
@ -25,13 +27,47 @@ export default function AdminPromptsPage() {
useEffect(() => { load() }, [session.token])
const edit = (prompt) => {
setError('')
setNotice('')
setPreview(null)
setForm({
id: prompt.id,
slug: prompt.slug,
name: prompt.name,
description: prompt.description || '',
category: prompt.category || 'uncategorized',
prompt_type: prompt.prompt_type || 'base',
template: prompt.template || '',
required_feature: prompt.required_feature || 'ai_calls',
active: Boolean(prompt.active),
is_system_default: Boolean(prompt.is_system_default)
})
}
const save = async (e) => {
e.preventDefault()
setError('')
setNotice('')
try {
await api('/api/prompts', { token: session.token, method: 'POST', body: form })
setForm(emptyForm)
const body = {
slug: form.slug,
name: form.name,
description: form.description,
category: form.category,
prompt_type: form.prompt_type,
template: form.template,
required_feature: form.required_feature,
active: form.active
}
if (form.id) {
await api(`/api/prompts/${form.id}`, { token: session.token, method: 'PUT', body })
setNotice('Prompt gespeichert.')
} else {
await api('/api/prompts', { token: session.token, method: 'POST', body })
setForm(emptyForm)
setNotice('Prompt angelegt.')
}
setPreview(null)
await load()
} catch (err) {
@ -39,6 +75,19 @@ export default function AdminPromptsPage() {
}
}
const resetDefault = async () => {
if (!form.id) return
setError('')
try {
const restored = await api(`/api/prompts/${form.id}/reset`, { token: session.token, method: 'POST' })
edit(restored)
await load()
setNotice('Auf System-Default zurückgesetzt.')
} catch (err) {
setError(err.message)
}
}
const runPreview = async (prompt) => {
setError('')
try {
@ -48,52 +97,62 @@ export default function AdminPromptsPage() {
}
}
const runExecute = async (prompt) => {
setError('')
setNotice('')
try {
await api('/api/prompts/execute', { token: session.token, method: 'POST', body: { prompt_id: prompt.id } })
} catch (err) {
setNotice(`Ausführung blockiert (erwartet, solange kein Provider): ${err.message}`)
}
}
return (
<section className="card">
<h1>Prompts</h1>
<p className="muted">Texte liegen in der Datenbank bzw. in <code>backend/config/prompts.seed.json</code>, nicht im Anwendungscode. Domain-Prompts gehören nicht in dieses Gerüst.</p>
<p className="muted">
Alle Modelltexte liegen in der Datenbank, Seed in <code>backend/config/prompts.seed.json</code>.
Keine Prompt-Texte im Anwendungscode. Änderungen hier gelten nach dem Speichern.
</p>
{error && <p className="error">{error}</p>}
{notice && <p className="muted">{notice}</p>}
{prompts.length === 0 && <p>Noch keine Prompts. Bibliothek bewusst leer.</p>}
{prompts.length === 0 && <p>Noch keine Prompts in der Datenbank. Backend neu starten, damit der Seed geladen wird.</p>}
<ul className="stack">
{prompts.map((prompt) => (
<li key={prompt.id} className="row-item">
<div>
<strong>{prompt.name}</strong>
<div className="muted">{prompt.slug} · {prompt.prompt_type}</div>
<div className="muted">
{prompt.slug} · {prompt.category} · {prompt.prompt_type}
{prompt.is_system_default ? ' · System' : ''}
</div>
</div>
<div className="row-actions">
<button type="button" className={form.id === prompt.id ? 'ghost active' : 'ghost'} onClick={() => edit(prompt)}>
Bearbeiten
</button>
<button type="button" className="ghost" onClick={() => runPreview(prompt)}>Preview</button>
<button type="button" className="ghost" onClick={() => runExecute(prompt)}>Execute</button>
</div>
</li>
))}
</ul>
{preview && <pre className="code">{JSON.stringify(preview, null, 2)}</pre>}
<form className="stack" onSubmit={save}>
<h2>Prompt in der DB anlegen</h2>
<h2>{form.id ? `Bearbeiten: ${form.slug}` : 'Neuen Prompt anlegen'}</h2>
<label>Slug <input value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} required /></label>
<label>Name <input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /></label>
<label>Beschreibung <input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></label>
<label>Kategorie <input value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })} /></label>
<label>
Template
<textarea
rows={8}
rows={14}
value={form.template}
onChange={(e) => setForm({ ...form, template: e.target.value })}
placeholder="Nur {{now}} und [[SELF]] / [[PERSON:…]] / [[PLACE:…]] — keine Klarnamen."
placeholder="Nur registrierte {{keys}} und [[SELF]] / [[PERSON:…]] / [[PLACE:…]]"
/>
</label>
<button type="submit">Speichern</button>
<div className="row-actions">
<button type="submit">Speichern</button>
{form.is_system_default && (
<button type="button" className="ghost" onClick={resetDefault}>Default wiederherstellen</button>
)}
{form.id && (
<button type="button" className="ghost" onClick={() => { setForm(emptyForm); setPreview(null) }}>
Neu anlegen
</button>
)}
</div>
</form>
</section>
)

View File

@ -0,0 +1,171 @@
import { useEffect, useState } from 'react'
import { api } from '../api.js'
import { useAuth } from '../context/AuthContext.jsx'
const emptyRole = () => ({
role: '',
name: '',
url: '',
model: '',
zdr: true,
no_train: true,
key: ''
})
function statusLabel(role) {
if (role.role === 'detect' && !role.url) return 'Muster, kein LLM'
if (role.role === 'detect' && role.url && !role.local && role.ready) {
return role.remote_plaintext_allowed === false
? 'extern konfiguriert, Produktiv blockiert'
: 'bereit (nur Development/Test, extern)'
}
if (role.ready) return 'bereit'
if (!role.key_present && !role.local) return 'Key fehlt'
if (!role.url) return 'URL fehlt'
return 'nicht bereit'
}
export default function AdminProvidersPage() {
const { session } = useAuth()
const [data, setData] = useState(null)
const [forms, setForms] = useState({})
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [saving, setSaving] = useState('')
const load = () =>
api('/api/admin/providers', { token: session.token })
.then((payload) => {
setData(payload)
const next = {}
for (const role of payload.roles) {
next[role.role] = {
role: role.role,
name: role.name || '',
url: role.url || '',
model: role.model || '',
zdr: Boolean(role.zdr),
no_train: Boolean(role.no_train),
key: ''
}
}
setForms(next)
})
.catch((e) => setError(e.message))
useEffect(() => { load() }, [session.token])
const update = (role, field, value) => {
setForms((prev) => ({ ...prev, [role]: { ...(prev[role] || emptyRole()), [field]: value } }))
}
const save = async (role) => {
const form = forms[role]
if (!form) return
setError('')
setNotice('')
setSaving(role)
try {
const body = {
name: form.name,
url: form.url,
model: form.model,
zdr: form.zdr,
no_train: form.no_train
}
if (form.key.trim()) body.key = form.key.trim()
const payload = await api(`/api/admin/providers/${role}`, {
token: session.token,
method: 'PUT',
body
})
setData(payload)
setForms((prev) => ({ ...prev, [role]: { ...prev[role], key: '' } }))
setNotice(form.key.trim() ? `${role}: gespeichert, Key in .env geschrieben.` : `${role}: gespeichert.`)
} catch (err) {
setError(err.message)
} finally {
setSaving('')
}
}
return (
<section className="card">
<h1>Schnittstellen</h1>
<p className="muted">
Zwei eigene Einträge: Maskierung (Detect-Call) und Sprachmodell (Dialogzug und Journalentwurf).
Externes Klartext-Detect ist nur in Development/Test zulässig. Produktiv bleibt Detect lokal oder bei Mustern.
Der Secret-Key wird nie angezeigt und nie in der Datenbank gespeichert.
</p>
{error && <p className="error">{error}</p>}
{notice && <p>{notice}</p>}
{data && (
<>
{data.roles.map((role) => {
const form = forms[role.role] || emptyRole()
return (
<form
key={role.role}
className="stack"
onSubmit={(e) => { e.preventDefault(); save(role.role) }}
>
<h2>{role.title || role.role}</h2>
{role.task && <p className="muted">{role.task}</p>}
<dl className="meta">
<dt>Status</dt>
<dd>{statusLabel(role)}</dd>
<dt>Key</dt>
<dd>{role.key_present ? (role.key_source === 'generate' ? 'über Sprachmodell' : 'gesetzt') : 'fehlt'}</dd>
<dt>Modus</dt>
<dd>{role.local ? 'lokal' : 'extern'} · {role.mode}</dd>
</dl>
<label>
Name
<input value={form.name} onChange={(e) => update(role.role, 'name', e.target.value)} />
</label>
<label>
URL
<input
value={form.url}
onChange={(e) => update(role.role, 'url', e.target.value)}
placeholder={role.role === 'detect' ? 'http://127.0.0.1:11434/v1/chat/completions' : ''}
/>
</label>
<label>
Modell
<input value={form.model} onChange={(e) => update(role.role, 'model', e.target.value)} />
</label>
<label className="check">
<input type="checkbox" checked={form.zdr} onChange={(e) => update(role.role, 'zdr', e.target.checked)} />
Zero Data Retention
</label>
<label className="check">
<input
type="checkbox"
checked={form.no_train}
onChange={(e) => update(role.role, 'no_train', e.target.checked)}
/>
kein Provider-Training
</label>
<label>
API-Key {role.key_present ? '(leer lassen, um den bestehenden zu behalten)' : '(pflichtig für externe URL)'}
<input
type="password"
autoComplete="off"
value={form.key}
onChange={(e) => update(role.role, 'key', e.target.value)}
placeholder={role.role === 'detect' ? 'leer = Key des Sprachmodells' : ''}
/>
</label>
<button type="submit" disabled={saving === role.role}>
{saving === role.role ? 'Speichert …' : 'Speichern'}
</button>
</form>
)
})}
<p className="muted">Ohne ZDR und ohne Trainingsverbot bleibt das Sprachmodell fail-closed. Maskierung wählt keine Operation. Gateway lässt sich darüber nicht umgehen.</p>
</>
)}
</section>
)
}

View File

@ -45,6 +45,7 @@ export default function AdminUsersPage() {
<h1>Nutzer</h1>
<p className="muted">Ein Login ist ein Profil. Keine Profilumschaltung, kein Vereinsmodell.</p>
{error && <p className="error">{error}</p>}
<div className="table-scroll">
<table className="plain">
<thead>
<tr><th>Name</th><th>E-Mail</th><th>Rolle</th><th>Status</th><th /></tr>
@ -65,6 +66,7 @@ export default function AdminUsersPage() {
))}
</tbody>
</table>
</div>
<form className="stack" onSubmit={create}>
<h2>Nutzer anlegen</h2>
<label>Name <input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /></label>

View File

@ -1,18 +1,32 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api.js'
import CallTrace from '../components/CallTrace.jsx'
import { useAuth } from '../context/AuthContext.jsx'
function convLabel(item, index) {
const title = item.title && item.title !== 'Gespräch' ? item.title : `Gespräch ${index + 1}`
return `${title} · ${item.message_count || 0}`
}
export default function DialoguePage() {
const { session } = useAuth()
const { session, isAdmin } = useAuth()
const [conversations, setConversations] = useState([])
const [activeId, setActiveId] = useState(null)
const [messages, setMessages] = useState([])
const [draft, setDraft] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [egress, setEgress] = useState(null)
const [trace, setTrace] = useState(null)
const [decision, setDecision] = useState(null)
const loadList = () =>
api('/api/dialogue/conversations', { token: session.token })
.then(setConversations)
.then((items) => {
setConversations(items)
return items
})
.catch((e) => setError(e.message))
const openConversation = async (id) => {
@ -26,7 +40,12 @@ export default function DialoguePage() {
}
}
useEffect(() => { loadList() }, [session.token])
useEffect(() => {
loadList()
api('/api/journal/egress-status', { token: session.token })
.then(setEgress)
.catch(() => setEgress(null))
}, [session.token])
const start = async () => {
setError('')
@ -44,55 +63,108 @@ export default function DialoguePage() {
}
}
const send = async (e) => {
e.preventDefault()
if (!activeId || !draft.trim()) return
const removeConversation = async () => {
if (!activeId) return
if (!window.confirm('Dieses Gespräch und seine Nachrichten unwiderruflich löschen?')) return
setError('')
try {
await api(`/api/dialogue/conversations/${activeId}/messages`, {
token: session.token,
method: 'POST',
body: { body: draft, role: 'user' }
})
setDraft('')
await openConversation(activeId)
await loadList()
await api(`/api/dialogue/conversations/${activeId}`, { token: session.token, method: 'DELETE' })
setDecision(null)
setTrace(null)
const items = await loadList()
const next = items?.find((item) => item.id !== activeId) || items?.[0]
if (next) await openConversation(next.id)
else {
setActiveId(null)
setMessages([])
}
} catch (err) {
setError(err.message)
}
}
const send = async (e) => {
e.preventDefault()
if (!activeId || !draft.trim()) return
setError('')
setBusy(true)
try {
const result = await api(`/api/dialogue/conversations/${activeId}/turn`, {
token: session.token,
method: 'POST',
body: { body: draft }
})
setDraft('')
setMessages(result.messages || [])
setDecision(result.decision || null)
setTrace(result.trace || null)
await loadList()
} catch (err) {
setError(err.message)
setDecision(null)
setTrace(null)
if (activeId) await openConversation(activeId)
} finally {
setBusy(false)
}
}
return (
<section className="card">
<h1>Dialog</h1>
<p className="muted">
Nur Persistenz der Originalnachrichten. Keine intelligente Einstiegs-IA, keine automatischen Fäden, kein LLM-Summary.
</p>
{error && <p className="error">{error}</p>}
<button type="button" onClick={start}>Neues Gespräch</button>
<div className="dialogue-layout">
<ul className="stack">
{conversations.map((item) => (
<li key={item.id}>
<button type="button" className={item.id === activeId ? 'ghost active' : 'ghost'} onClick={() => openConversation(item.id)}>
{item.title || 'Gespräch'} · {item.message_count} Nachrichten
</button>
</li>
))}
</ul>
{activeId && (
<div>
<div className="message-list">
{messages.map((item) => (
<p key={item.id}><strong>{item.role}:</strong> {item.body}</p>
))}
</div>
<form className="stack" onSubmit={send}>
<textarea rows={4} value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Originaltext speichern …" />
<button type="submit">Speichern</button>
</form>
</div>
<section className="card dialogue-page">
<header className="dialogue-head">
<p className="dialogue-crumb">
Dev/Test-Harness · kein produktiver Gesprächsraum · Journal-Tage unter <Link to="/journal">Journal</Link>
</p>
<p className="muted">Diese Ansicht ist nur für Diagnose und Persistenztests. Conversations hier sind unzugeordnet.</p>
{egress && !egress.ready && (
<p className="error">
Kein Folgeimpuls möglich: {egress.reason === 'no_egress_provider_configured'
? 'Sprachmodell ist nicht bereit (Key oder Policy).'
: egress.reason}
</p>
)}
{error && <p className="error">{error}</p>}
<div className="dialogue-toolbar">
<div className="dialogue-switcher">
{conversations.map((item, index) => (
<button
key={item.id}
type="button"
className={item.id === activeId ? 'ghost active' : 'ghost'}
onClick={() => openConversation(item.id)}
>
{convLabel(item, index)}
</button>
))}
<button type="button" className="ghost" onClick={start}>Neues Gespräch</button>
</div>
{activeId && (
<div className="row-actions dialogue-tools">
<button type="button" className="ghost" onClick={removeConversation} disabled={busy}>
Löschen
</button>
</div>
)}
</div>
</header>
<div className="dialogue-layout">
<div className="dialogue-thread">
<div className="message-list">
{messages.map((item) => (
<p key={item.id} className={`msg ${item.role}`}>
<strong>{item.role === 'assistant' ? 'Kanshō' : 'Du'}:</strong> {item.body}
</p>
))}
{!activeId && <p className="muted">Noch kein Gespräch. Lege eines an und erzähle.</p>}
</div>
{activeId && (
<form className="dialogue-composer" onSubmit={send}>
<textarea rows={3} value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Erzählen …" />
<button type="submit" disabled={busy}>{busy ? 'Antwortet …' : 'Senden'}</button>
</form>
)}
{isAdmin && <CallTrace decision={decision} trace={trace} />}
</div>
</div>
</section>
)

View File

@ -1,11 +1,13 @@
import { Link } from 'react-router-dom'
export default function HomePage() {
return (
<section className="card">
<h1>Start</h1>
<p>
Der Produktrahmen läuft. Die Startlogik bleibt fachlich offen: Kontinuität des Dialogs
statt Funktions-Dashboard.
Der Einstieg bleibt bewusst dünn: kein Funktions-Dashboard. Der MVP-Kern liegt im Journal.
</p>
<p><Link to="/journal">Zum Journal</Link></p>
<p className="muted">Keine Tracking-Kacheln, keine Aufgabenliste, keine Diagnose.</p>
</section>
)

View File

@ -0,0 +1,251 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api } from '../api.js'
import CallTrace from '../components/CallTrace.jsx'
import DayScratch from '../components/DayScratch.jsx'
import { useAuth } from '../context/AuthContext.jsx'
import { entryTitle } from '../journal/document.js'
function convLabel(item, index) {
const title = item.title && item.title !== 'Gespräch' ? item.title : `Gespräch ${index + 1}`
return `${title} · ${item.message_count || 0}`
}
export default function JournalDayPage() {
const { spaceId, dayId } = useParams()
const { session, isAdmin } = useAuth()
const navigate = useNavigate()
const [payload, setPayload] = useState(null)
const [activeId, setActiveId] = useState(null)
const [messages, setMessages] = useState([])
const [draft, setDraft] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [egress, setEgress] = useState(null)
const [trace, setTrace] = useState(null)
const [decision, setDecision] = useState(null)
const [chooseSources, setChooseSources] = useState(false)
const loadDay = async (preferId) => {
const data = await api(`/api/journal/days/${dayId}`, { token: session.token })
setPayload(data)
const ids = data.conversations.map((item) => item.id)
const nextId = [preferId, activeId, ids.at(-1)].find((id) => id && ids.includes(id))
if (nextId) {
const conv = await api(`/api/journal/conversations/${nextId}`, { token: session.token })
setActiveId(nextId)
setMessages(conv.messages || [])
} else {
setActiveId(null)
setMessages([])
}
return data
}
useEffect(() => {
setPayload(null)
setActiveId(null)
setMessages([])
loadDay().catch((err) => setError(err.message))
api('/api/journal/egress-status', { token: session.token })
.then(setEgress)
.catch(() => setEgress(null))
}, [session.token, dayId])
const startConversation = async () => {
setError('')
setChooseSources(false)
try {
const conv = await api(`/api/journal/days/${dayId}/conversations`, {
token: session.token,
method: 'POST',
body: { title: 'Gespräch' }
})
await loadDay(conv.id)
} catch (err) {
setError(err.message)
}
}
const removeConversation = async () => {
if (!activeId) return
if (!window.confirm('Dieses Gespräch und seine Nachrichten unwiderruflich löschen?')) return
setError('')
try {
await api(`/api/journal/conversations/${activeId}`, { token: session.token, method: 'DELETE' })
setDecision(null)
setTrace(null)
await loadDay()
} catch (err) {
setError(err.message)
}
}
const send = async (event) => {
event.preventDefault()
if (!activeId || !draft.trim()) return
setError('')
setBusy(true)
try {
const result = await api(`/api/journal/conversations/${activeId}/turn`, {
token: session.token,
method: 'POST',
body: { body: draft }
})
setDraft('')
setMessages(result.messages || [])
setDecision(result.decision || null)
setTrace(result.trace || null)
await loadDay(activeId)
} catch (err) {
setError(err.message)
setDecision(null)
setTrace(null)
await loadDay(activeId)
} finally {
setBusy(false)
}
}
const generate = async (conversationIds) => {
setError('')
setBusy(true)
setChooseSources(false)
try {
const body = {}
if (conversationIds?.length) body.conversation_ids = conversationIds
const result = await api(`/api/journal/days/${dayId}/generate`, {
token: session.token,
method: 'POST',
body
})
setDecision(null)
setTrace(result.trace || null)
await loadDay(activeId)
navigate(`/journal/${spaceId}/${dayId}/entry?draft=1`)
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
const requestGenerate = () => {
if (payload?.consolidation_offer) {
setChooseSources(true)
return
}
generate(activeId ? [activeId] : undefined)
}
if (!payload) {
return <section className="card">{error ? <p className="error">{error}</p> : <p>Lädt </p>}</section>
}
const conversations = payload.conversations || []
return (
<section className="card dialogue-page">
<header className="dialogue-head">
<p className="dialogue-crumb">
<Link to={`/journal/${spaceId}`}>{payload.space?.title}</Link>
{' · '}
{payload.day?.calendar_date}
</p>
{egress && !egress.ready && (
<p className="error">
Kein Folgeimpuls möglich: {egress.reason === 'no_egress_provider_configured'
? 'Sprachmodell ist nicht bereit (Key oder Policy unter Admin → Schnittstellen).'
: egress.reason}
</p>
)}
{error && <p className="error">{error}</p>}
<div className="dialogue-toolbar">
<div className="dialogue-switcher">
{conversations.map((item, index) => (
<button
key={item.id}
type="button"
className={item.id === activeId ? 'ghost active' : 'ghost'}
onClick={() => loadDay(item.id).catch((err) => setError(err.message))}
>
{convLabel(item, index)}
</button>
))}
<button type="button" className="ghost" onClick={startConversation}>Neues Gespräch</button>
</div>
<div className="row-actions dialogue-tools">
{activeId && (
<button type="button" className="ghost" onClick={removeConversation} disabled={busy}>
Löschen
</button>
)}
<button type="button" onClick={requestGenerate} disabled={busy || !conversations.length}>
Tagebuch
</button>
<Link to={payload.entries?.[0] ? `/journal/${spaceId}/${dayId}/entry?entry=${payload.entries[payload.entries.length - 1].id}` : `/journal/${spaceId}/${dayId}/entry`}>Editor</Link>
</div>
</div>
{chooseSources && (
<div className="generate-choice">
<p>Ähnliche Gespräche an diesem Tag. Gemeinsamen Entwurf anbieten du entscheidest.</p>
<div className="row-actions">
<button type="button" onClick={() => generate(activeId ? [activeId] : [])} disabled={!activeId}>
Dieses Gespräch
</button>
<button type="button" className="ghost" onClick={() => generate(conversations.map((item) => item.id))}>
Alle des Tages
</button>
<button type="button" className="ghost" onClick={() => setChooseSources(false)}>
Abbrechen
</button>
</div>
</div>
)}
{(payload.current_draft || payload.entries?.length > 0) && (
<p className="dialogue-meta muted">
{payload.current_draft && (
<Link to={`/journal/${spaceId}/${dayId}/entry?draft=1`}>Neuer Entwurf</Link>
)}
{payload.entries?.map((entry) => (
<Link key={entry.id} to={`/journal/${spaceId}/${dayId}/entry?entry=${entry.id}`}>
{entryTitle(entry.title) || 'Eintrag'}
</Link>
))}
</p>
)}
</header>
<div className="dialogue-layout has-scratch">
<div className="dialogue-thread">
<div className="message-list">
{messages.map((item) => (
<p key={item.id} className={`msg ${item.role}`}>
<strong>{item.role === 'assistant' ? 'Kanshō' : 'Du'}:</strong> {item.body}
</p>
))}
{!activeId && <p className="muted">Noch kein Gespräch. Lege eines an und erzähle.</p>}
</div>
{activeId && (
<form className="dialogue-composer" onSubmit={send}>
<textarea
rows={3}
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Erzählen …"
/>
<button type="submit" disabled={busy}>{busy ? 'Antwortet …' : 'Senden'}</button>
</form>
)}
{isAdmin && <CallTrace decision={decision} trace={trace} />}
</div>
<DayScratch
key={dayId}
dayId={dayId}
token={session.token}
items={payload.scratch || []}
onError={setError}
/>
</div>
</section>
)
}

View File

@ -0,0 +1,372 @@
import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { api, apiBlob, apiUpload } from '../api.js'
import JournalDocumentEditor from '../components/JournalDocumentEditor.jsx'
import { useAuth } from '../context/AuthContext.jsx'
import { useUnsavedChanges } from '../context/UnsavedChanges.jsx'
import { entryTitle, formatWhen, originLabel } from '../journal/document.js'
export default function JournalEditorPage() {
const { spaceId, dayId } = useParams()
const [params] = useSearchParams()
const entryQuery = params.get('entry') || ''
const wantDraft = params.get('draft') === '1'
const { session } = useAuth()
const navigate = useNavigate()
const editorRef = useRef(null)
const entryIdRef = useRef(entryQuery)
const skipHydrate = useRef(false)
const [day, setDay] = useState(null)
const [entryId, setEntryId] = useState(entryQuery)
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [contentKey, setContentKey] = useState(0)
const [versions, setVersions] = useState([])
const [media, setMedia] = useState([])
const [previews, setPreviews] = useState({})
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [savedEntryId, setSavedEntryId] = useState('')
const [currentVersionId, setCurrentVersionId] = useState('')
const { dirty, setDirty } = useUnsavedChanges()
const savedRef = useRef({ title: '', body: '' })
const markClean = (nextTitle, nextBody) => {
const titleValue = entryTitle(nextTitle || '')
const bodyValue = nextBody || ''
savedRef.current = { title: titleValue, body: bodyValue }
setDirty(false)
}
const noteDirty = (nextTitle, nextBody) => {
const saved = savedRef.current
setDirty(entryTitle(nextTitle || '') !== saved.title || (nextBody || '') !== saved.body)
}
const showLoaded = (entry, draft) => {
setContentKey((n) => n + 1)
if (entry) {
setEntryId(entry.id)
entryIdRef.current = entry.id
setTitle(entryTitle(entry.title || ''))
setBody(entry.body || '')
setVersions(entry.versions || [])
setMedia(entry.media || [])
setCurrentVersionId(entry.current_version_id || entry.versions?.at(-1)?.id || '')
markClean(entry.title || '', entry.body || '')
return
}
setEntryId('')
entryIdRef.current = ''
setTitle(entryTitle(draft?.title || ''))
setBody(draft?.body || '')
setVersions([])
setMedia([])
setSavedEntryId('')
setCurrentVersionId('')
markClean(draft?.title || '', draft?.body || '')
}
const hydrate = async () => {
const payload = await api(`/api/journal/days/${dayId}`, { token: session.token })
setDay(payload)
if (wantDraft) {
showLoaded(null, payload.current_draft)
const existing = payload.entries?.at(-1)
if (existing?.id) {
setSavedEntryId(existing.id)
const entry = await api(`/api/journal/entries/${existing.id}`, { token: session.token })
setVersions(entry.versions || [])
setCurrentVersionId(entry.current_version_id || entry.versions?.at(-1)?.id || '')
}
return
}
const wanted = entryQuery || payload.entries.at(-1)?.id
if (wanted) {
const entry = await api(`/api/journal/entries/${wanted}`, { token: session.token })
showLoaded(entry)
setSavedEntryId(entry.id)
} else {
showLoaded(null, payload.current_draft)
}
}
useEffect(() => {
if (skipHydrate.current) {
skipHydrate.current = false
return
}
hydrate().catch((err) => setError(err.message))
}, [session.token, dayId, entryQuery, wantDraft])
useEffect(() => () => setDirty(false), [setDirty])
useEffect(() => {
let cancelled = false
const urls = {}
Promise.all(
media.map(async (item) => {
const url = await apiBlob(`/api/journal/media/${item.id}`, { token: session.token })
urls[item.id] = url
})
).then(() => {
if (!cancelled) setPreviews(urls)
}).catch(() => {})
return () => {
cancelled = true
Object.values(urls).forEach((url) => URL.revokeObjectURL(url))
}
}, [media, session.token])
const persist = async (nextTitle, nextBody, nextEntryId = entryIdRef.current, origin) => {
const saved = await api('/api/journal/entries', {
token: session.token,
method: 'POST',
body: {
journal_day_id: dayId,
entry_id: nextEntryId || undefined,
title: entryTitle(nextTitle),
body: nextBody,
origin: origin || (nextEntryId ? 'user_edit' : (day?.current_draft ? 'accepted_draft' : 'user_edit')),
source_conversation_ids: day?.current_draft?.source_conversation_ids || []
}
})
entryIdRef.current = saved.id
setEntryId(saved.id)
setTitle(entryTitle(saved.title || ''))
setBody(saved.body || '')
const fresh = await api(`/api/journal/entries/${saved.id}`, { token: session.token })
setVersions(fresh.versions || [])
setMedia(fresh.media || [])
setCurrentVersionId(fresh.current_version_id || fresh.versions?.at(-1)?.id || '')
setSavedEntryId(saved.id)
markClean(saved.title || '', saved.body || '')
if (entryQuery !== saved.id) {
skipHydrate.current = true
navigate(`/journal/${spaceId}/${dayId}/entry?entry=${saved.id}`, { replace: true })
}
return saved
}
const currentMarkdown = () => {
if (editorRef.current?.getMarkdown) return editorRef.current.getMarkdown()
return body
}
const save = async (event) => {
event.preventDefault()
setBusy(true)
setError('')
setNotice('')
try {
const nextBody = currentMarkdown()
if (wantDraft && savedEntryId) {
await persist(title, nextBody, savedEntryId, 'accepted_draft')
setNotice('Als neue Version des Eintrags übernommen.')
} else {
await persist(title, nextBody)
setNotice('Gespeichert.')
}
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
const saveAsNewEntry = async () => {
setBusy(true)
setError('')
setNotice('')
try {
await persist(title, currentMarkdown(), '', 'accepted_draft')
setNotice('Als eigenen Eintrag gespeichert.')
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
const revertChanges = () => {
const saved = savedRef.current
setTitle(saved.title)
setBody(saved.body)
setContentKey((n) => n + 1)
setDirty(false)
setNotice('Änderungen verworfen.')
}
const restore = async (versionId) => {
const targetId = entryId || savedEntryId
if (!targetId) return
if (versionId === (currentVersionId || versions.at(-1)?.id)) return
if (dirty && !window.confirm('Ungespeicherte Änderungen gehen verloren. Diese Version wiederherstellen?')) return
setError('')
setNotice('')
try {
const saved = await api(`/api/journal/entries/${targetId}/restore`, {
token: session.token,
method: 'POST',
body: { version_id: versionId }
})
const fresh = await api(`/api/journal/entries/${targetId}`, { token: session.token })
showLoaded({ ...fresh, title: saved.title, body: saved.body })
setSavedEntryId(targetId)
skipHydrate.current = true
navigate(`/journal/${spaceId}/${dayId}/entry?entry=${targetId}`, { replace: true })
setNotice('Version wiederhergestellt.')
} catch (err) {
setError(err.message)
}
}
const remove = async () => {
if (!entryId) return
if (!window.confirm('Eintrag in den Papierkorb legen? Das ist noch nicht endgültig.')) return
setError('')
setNotice('')
try {
await api(`/api/journal/entries/${entryId}`, { token: session.token, method: 'DELETE' })
showLoaded(null)
setNotice('Im Papierkorb.')
} catch (err) {
setError(err.message)
}
}
const upload = async (file) => {
let currentId = entryIdRef.current
if (!currentId) {
const saved = await persist(title, currentMarkdown())
currentId = saved.id
}
const asset = await apiUpload(`/api/journal/entries/${currentId}/media`, { token: session.token, file })
const fresh = await api(`/api/journal/entries/${currentId}`, { token: session.token })
setMedia(fresh.media || [])
return asset
}
const removeMedia = async (mediaId) => {
await api(`/api/journal/media/${mediaId}`, { token: session.token, method: 'DELETE' })
setMedia((current) => current.filter((item) => item.id !== mediaId))
}
return (
<section className="card journal-editor-page">
<p className="muted">
<Link to={`/journal/${spaceId}/${dayId}`}>Zurück zum Tag</Link>
{day?.day?.calendar_date ? ` · ${day.day.calendar_date}` : ''}
</p>
<h1>{wantDraft ? 'Neuer Entwurf' : 'Journal Editor'}{dirty ? ' · ungespeichert' : ''}</h1>
<p className="lede">
{wantDraft
? 'Vorschlag aus dem Gespräch. Speichern legt eine neue Version des bestehenden Eintrags an; die vorigen Fassungen bleiben in der Liste.'
: 'Redigieren als Fließtext: Absätze und Markierungen formatieren, Bilder und Videos an die Schreibmarke setzen und verschieben. Gespeichert als Markdown. Eine neue Generierung überschreibt diese Fassung nicht still.'}
</p>
{error && <p className="error">{error}</p>}
{notice && <p className="muted">{notice}</p>}
<form className="stack" onSubmit={save}>
<input
value={title}
onChange={(e) => {
setTitle(e.target.value)
noteDirty(e.target.value, currentMarkdown())
}}
placeholder="Überschrift"
/>
<JournalDocumentEditor
ref={editorRef}
body={body}
contentKey={contentKey}
onChange={(next) => {
setBody(next)
noteDirty(title, next)
}}
media={media}
previews={previews}
onUpload={async (file) => {
setError('')
setBusy(true)
try {
return await upload(file)
} catch (err) {
setError(err.message)
return null
} finally {
setBusy(false)
}
}}
onPlaced={async () => {
setError('')
setBusy(true)
try {
await persist(title, currentMarkdown())
setNotice('Gespeichert.')
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}}
onRemoveMedia={removeMedia}
disabled={busy}
/>
<div className="row-actions">
<button type="submit" disabled={busy}>
{busy ? 'Speichert …' : (wantDraft && savedEntryId ? 'Als neue Version speichern' : 'Speichern')}
</button>
{dirty && versions.length === 0 && (
<button type="button" className="ghost" onClick={revertChanges} disabled={busy}>
Alle Änderungen rückgängig machen
</button>
)}
{wantDraft && savedEntryId && (
<button type="button" className="ghost" onClick={saveAsNewEntry} disabled={busy}>
Als eigenen Eintrag speichern
</button>
)}
{entryId && (
<button type="button" className="ghost" onClick={remove}>
In den Papierkorb
</button>
)}
</div>
</form>
{versions.length > 0 && (
<div className="stack">
<h2>Versionen</h2>
<ul className="stack">
{versions.map((item) => {
const isCurrent = item.id === (currentVersionId || versions.at(-1)?.id)
return (
<li key={item.id} className="row-item">
<span>
{entryTitle(item.title) || originLabel(item.origin)}
{' · '}
{originLabel(item.origin)}
{' · '}
{formatWhen(item.created)}
{isCurrent ? ' · Aktuell' : ''}
</span>
{isCurrent ? (
dirty ? (
<button type="button" className="ghost" onClick={revertChanges} disabled={busy}>
Alle Änderungen rückgängig machen
</button>
) : null
) : (
<button type="button" className="ghost" onClick={() => restore(item.id)} disabled={busy}>
Wiederherstellen
</button>
)}
</li>
)
})}
</ul>
</div>
)}
</section>
)
}

View File

@ -0,0 +1,31 @@
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { api } from '../api.js'
import { useAuth } from '../context/AuthContext.jsx'
export default function JournalSourcePage() {
const { spaceId, dayId, conversationId } = useParams()
const { session } = useAuth()
const [data, setData] = useState(null)
const [error, setError] = useState('')
useEffect(() => {
api(`/api/journal/conversations/${conversationId}`, { token: session.token })
.then(setData)
.catch((err) => setError(err.message))
}, [session.token, conversationId])
return (
<section className="card">
<p className="muted"><Link to={`/journal/${spaceId}/${dayId}`}>Zurück zum Tag</Link></p>
<h1>Quelldialog</h1>
<p className="muted">Originalnachrichten. Derived und Journal Draft ersetzen diese Quelle nicht.</p>
{error && <p className="error">{error}</p>}
<div className="message-list">
{(data?.messages || []).map((item) => (
<p key={item.id}><strong>{item.role}:</strong> {item.body}</p>
))}
</div>
</section>
)
}

View File

@ -0,0 +1,77 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api } from '../api.js'
import { useAuth } from '../context/AuthContext.jsx'
function todayLocal() {
const now = new Date()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${now.getFullYear()}-${month}-${day}`
}
export default function JournalSpacePage() {
const { spaceId } = useParams()
const navigate = useNavigate()
const { session } = useAuth()
const [space, setSpace] = useState(null)
const [days, setDays] = useState([])
const [date, setDate] = useState(todayLocal())
const [error, setError] = useState('')
useEffect(() => {
api(`/api/journal/spaces/${spaceId}`, { token: session.token })
.then((data) => {
setSpace(data.space)
setDays(data.days || [])
})
.catch((err) => setError(err.message))
}, [session.token, spaceId])
const openDay = async (calendarDate) => {
setError('')
try {
const payload = await api(`/api/journal/spaces/${spaceId}/days`, {
token: session.token,
method: 'POST',
body: { calendar_date: calendarDate }
})
navigate(`/journal/${spaceId}/${payload.day.id}`)
} catch (err) {
setError(err.message)
}
}
return (
<section className="card">
<p className="muted"><Link to="/journal">Alle Spaces</Link></p>
<h1>{space?.title || 'Space'}</h1>
<p className="lede">Tage chronologisch. Der Kalendertag ist nicht die Dialogzeit.</p>
{error && <p className="error">{error}</p>}
<form
className="stack"
onSubmit={(event) => {
event.preventDefault()
openDay(date)
}}
>
<label>
Tag öffnen
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} required />
</label>
<button type="submit">Tag öffnen</button>
</form>
<ul className="stack">
{days.map((item) => (
<li key={item.id} className="row-item">
<Link to={`/journal/${spaceId}/${item.id}`}>
{item.calendar_date}
{item.entry_title ? ` · ${item.entry_title}` : ''}
{item.entry_count > 1 ? ` (${item.entry_count})` : ''}
</Link>
</li>
))}
</ul>
</section>
)
}

View File

@ -0,0 +1,87 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api.js'
import { useAuth } from '../context/AuthContext.jsx'
export default function JournalSpacesPage() {
const { session } = useAuth()
const [spaces, setSpaces] = useState([])
const [title, setTitle] = useState('')
const [renameId, setRenameId] = useState(null)
const [renameTitle, setRenameTitle] = useState('')
const [error, setError] = useState('')
const load = () =>
api('/api/journal/spaces', { token: session.token })
.then(setSpaces)
.catch((err) => setError(err.message))
useEffect(() => { load() }, [session.token])
const create = async (event) => {
event.preventDefault()
setError('')
try {
await api('/api/journal/spaces', { token: session.token, method: 'POST', body: { title } })
setTitle('')
await load()
} catch (err) {
setError(err.message)
}
}
const rename = async (event) => {
event.preventDefault()
if (!renameId) return
setError('')
try {
await api(`/api/journal/spaces/${renameId}`, {
token: session.token,
method: 'PATCH',
body: { title: renameTitle }
})
setRenameId(null)
await load()
} catch (err) {
setError(err.message)
}
}
return (
<section className="card">
<h1>Journal</h1>
<p className="lede">Reflection Spaces anlegen, umbenennen und öffnen. Chronologie liegt im Space.</p>
{error && <p className="error">{error}</p>}
<form className="stack" onSubmit={create}>
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Neuer Space, z. B. Alltag" required />
<button type="submit">Space anlegen</button>
</form>
<ul className="stack">
{spaces.map((space) => (
<li key={space.id} className="row-item">
<Link to={`/journal/${space.id}`}>{space.title || 'Space'}</Link>
<button
type="button"
className="ghost"
onClick={() => {
setRenameId(space.id)
setRenameTitle(space.title)
}}
>
Umbenennen
</button>
</li>
))}
</ul>
{renameId && (
<form className="stack" onSubmit={rename}>
<input value={renameTitle} onChange={(e) => setRenameTitle(e.target.value)} required />
<div className="row-actions">
<button type="submit">Speichern</button>
<button type="button" className="ghost" onClick={() => setRenameId(null)}>Abbrechen</button>
</div>
</form>
)}
</section>
)
}

View File

@ -1,14 +1,353 @@
import { useEffect, useState } from 'react'
import { useEffect, useId, useState } from 'react'
import { useAuth } from '../context/AuthContext.jsx'
import { api } from '../api.js'
const KIND_LABELS = {
journal_entry: 'Finale Fassung (Stimme)',
imported_text: 'Import',
dialogue_style: 'Dialogstil (schwächer)'
}
const GOVERNANCE_LABELS = {
learning: 'Lernen — geeignete Quellen dürfen das Profil weiterentwickeln',
advising: 'Vorschlagen — Änderungen erst nach deiner Bestätigung',
frozen: 'Eingefroren — neue Beobachtungen ändern nichts, du kannst weiter manuell editieren'
}
function kindLabel(kind) {
return KIND_LABELS[kind] || kind
}
function downloadJson(filename, data) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
function DocumentTransfer({ token, exportPath, restorePath, filename, confirmText, onRestored, onError }) {
const inputId = useId()
const exportDoc = async () => {
onError('')
try {
downloadJson(filename, await api(exportPath, { token }))
} catch (err) {
onError(err.message)
}
}
const restoreFile = async (event) => {
const file = event.target.files?.[0]
event.target.value = ''
if (!file) return
onError('')
if (!window.confirm(confirmText)) return
try {
const document = JSON.parse(await file.text())
onRestored(await api(restorePath, { token, method: 'POST', body: document }))
} catch (err) {
onError(err.message || 'JSON konnte nicht gelesen werden.')
}
}
return (
<div className="row-actions">
<button type="button" className="ghost" onClick={exportDoc}>JSON exportieren</button>
<label className="button-file" htmlFor={inputId}>JSON wiederherstellen</label>
<input id={inputId} type="file" accept="application/json,.json" hidden onChange={restoreFile} />
</div>
)
}
const CONTEXT_HINTS = [
{ value: 'vacation_diary', label: 'Urlaubstagebuch → autobiografisches Journal' },
{ value: 'autobiographical_journal', label: 'Autobiografisches Journal' },
{ value: 'reflective', label: 'Reflektierender Text' }
]
const LIFECYCLE_LABELS = {
uninitialized: 'Noch kein bestätigtes Profil. Quellen sammeln, dann Initial Profile Build.',
initial_pending: 'Initiales Profilpaket ist offen. Nach der Prüfung wird das Profil bestätigt.',
confirmed: 'Profilhülle bestätigt. Neue Texte gehen in die Review-Queue, nicht in ein Blind-Reinfer.'
}
function FacetRow({ item, onSave }) {
const [value, setValue] = useState(item.value || '')
const [locked, setLocked] = useState(Boolean(item.locked))
useEffect(() => {
setValue(item.value || '')
setLocked(Boolean(item.locked))
}, [item.id, item.value, item.locked])
const dirty = value !== (item.value || '') || locked !== Boolean(item.locked)
return (
<li className="facet-row">
<div className="facet-head">
<strong>{item.label || item.facet_key}</strong>
<span className="muted">{item.layer || 'context'} · {item.origin}{item.locked ? ' · geschützt' : ''}</span>
</div>
{item.evidence && <p className="muted">{item.evidence}</p>}
<textarea rows={2} value={value} onChange={(e) => setValue(e.target.value)} />
<label className="inline">
<input type="checkbox" checked={locked} onChange={(e) => setLocked(e.target.checked)} />
Diese Facet vor automatischem Lernen schützen
</label>
<button type="button" disabled={!dirty} onClick={() => onSave(item.facet_key, { value, locked })}>
Facet speichern
</button>
</li>
)
}
function TraitRow({ item, onSave }) {
const [statement, setStatement] = useState(item.statement || '')
const [locked, setLocked] = useState(Boolean(item.locked))
useEffect(() => {
setStatement(item.statement || '')
setLocked(Boolean(item.locked))
}, [item.id, item.statement, item.locked])
const dirty = statement !== (item.statement || '') || locked !== Boolean(item.locked)
const exemplars = item.exemplars || []
return (
<li className="facet-row">
<div className="facet-head">
<strong>{item.label || item.slug}</strong>
<span className="muted">{item.facet_key} · {item.slug} · {item.origin}{item.locked ? ' · geschützt' : ''}</span>
</div>
<textarea rows={2} value={statement} onChange={(e) => setStatement(e.target.value)} />
{exemplars.length > 0 && (
<ul className="muted">
{exemplars.map((ref, index) => (
<li key={ref.id || index}>Beispiel{ref.occurred_at ? ` · ${String(ref.occurred_at).slice(0, 10)}` : ''}: {ref.excerpt}</li>
))}
</ul>
)}
<label className="inline">
<input type="checkbox" checked={locked} onChange={(e) => setLocked(e.target.checked)} />
Diesen Trait vor automatischem Lernen schützen
</label>
<button type="button" disabled={!dirty} onClick={() => onSave(item.slug, { statement, locked, facet_key: item.facet_key })}>
Trait speichern
</button>
</li>
)
}
export default function SettingsPage() {
const { session, isAdmin } = useAuth()
const [sub, setSub] = useState(null)
const [profile, setProfile] = useState(null)
const [interaction, setInteraction] = useState(null)
const [importText, setImportText] = useState('')
const [importDate, setImportDate] = useState('')
const [importHint, setImportHint] = useState('vacation_diary')
const [prefDrafts, setPrefDrafts] = useState({})
const [error, setError] = useState('')
const [review, setReview] = useState(null)
const [reviewPrompt, setReviewPrompt] = useState('')
const [reviewResult, setReviewResult] = useState('')
const [reviewBusy, setReviewBusy] = useState(false)
useEffect(() => {
const load = () => {
api('/api/subscription', { token: session.token }).then(setSub).catch(() => setSub(null))
}, [session.token])
api('/api/journal/writing-profile', { token: session.token }).then(setProfile).catch(() => setProfile(null))
api('/api/journal/writing-profile/review', { token: session.token }).then(setReview).catch(() => setReview(null))
api('/api/journal/interaction-profile', { token: session.token }).then(setInteraction).catch(() => setInteraction(null))
}
useEffect(() => { load() }, [session.token])
const patchWriting = async (body) => {
setError('')
try {
setProfile(await api('/api/journal/writing-profile', { token: session.token, method: 'PATCH', body }))
} catch (err) {
setError(err.message)
}
}
const saveFacet = async (facetKey, body) => {
setError('')
try {
setProfile(await api(`/api/journal/writing-profile/facets/${facetKey}`, {
token: session.token,
method: 'PATCH',
body
}))
} catch (err) {
setError(err.message)
}
}
const saveTrait = async (slug, body) => {
setError('')
try {
setProfile(await api(`/api/journal/writing-profile/traits/${slug}`, {
token: session.token,
method: 'PATCH',
body
}))
} catch (err) {
setError(err.message)
}
}
const resolveWriting = async (id, action) => {
setError('')
try {
setProfile(await api(`/api/journal/writing-profile/suggestions/${id}/${action}`, {
token: session.token,
method: 'POST'
}))
} catch (err) {
setError(err.message)
}
}
const importOwn = async (event) => {
event.preventDefault()
setError('')
try {
const next = await api('/api/journal/writing-profile/import', {
token: session.token,
method: 'POST',
body: {
body: importText,
occurred_at: importDate || undefined,
context_hint: importHint || undefined
}
})
setProfile(next)
setImportText('')
setReview(await api('/api/journal/writing-profile/review', { token: session.token }))
} catch (err) {
setError(err.message)
}
}
const copyReviewPrompt = async () => {
setError('')
setReviewBusy(true)
try {
const path = profile?.confirmed
? '/api/journal/writing-profile/review/paste'
: '/api/journal/writing-profile/initial-build/paste'
const opened = await api(path, {
token: session.token,
method: 'POST'
})
setReview(opened)
setReviewPrompt(opened.prompt || '')
setProfile(await api('/api/journal/writing-profile', { token: session.token }))
try {
await navigator.clipboard.writeText(opened.prompt || '')
} catch {
/* textarea remains for manual copy */
}
} catch (err) {
setError(err.message)
} finally {
setReviewBusy(false)
}
}
const runApiReview = async () => {
setError('')
setReviewBusy(true)
try {
const path = profile?.confirmed
? '/api/journal/writing-profile/review/api'
: '/api/journal/writing-profile/initial-build/api'
const next = await api(path, {
token: session.token,
method: 'POST'
})
if (next.profile) setProfile(next.profile)
setReview(next)
setReviewPrompt('')
} catch (err) {
setError(err.message)
} finally {
setReviewBusy(false)
}
}
const importReviewResult = async (event) => {
event.preventDefault()
setError('')
setReviewBusy(true)
try {
const next = await api('/api/journal/writing-profile/review/import', {
token: session.token,
method: 'POST',
body: { result: reviewResult, review_id: review?.open_review?.id }
})
if (next.profile) setProfile(next.profile)
setReview(next)
setReviewResult('')
} catch (err) {
setError(err.message)
} finally {
setReviewBusy(false)
}
}
const patchInteraction = async (body) => {
setError('')
try {
setInteraction(await api('/api/journal/interaction-profile', {
token: session.token,
method: 'PATCH',
body
}))
} catch (err) {
setError(err.message)
}
}
const savePref = async (item) => {
setError('')
const value = prefDrafts[`${item.scope}:${item.pref_key}`] ?? item.value
try {
const next = await api('/api/journal/interaction-profile/preferences', {
token: session.token,
method: 'PATCH',
body: { scope: item.scope, pref_key: item.pref_key, value, origin: 'manual' }
})
setInteraction(next)
} catch (err) {
setError(err.message)
}
}
const saveDefaultAsPref = async (item) => {
setError('')
const value = prefDrafts[`default:${item.scope}:${item.pref_key}`] ?? item.value
try {
const next = await api('/api/journal/interaction-profile/preferences', {
token: session.token,
method: 'PATCH',
body: { scope: item.scope, pref_key: item.pref_key, value, origin: 'manual' }
})
setInteraction(next)
} catch (err) {
setError(err.message)
}
}
const resolveInteraction = async (id, action) => {
setError('')
try {
setInteraction(await api(`/api/journal/interaction-profile/suggestions/${id}/${action}`, {
token: session.token,
method: 'POST'
}))
} catch (err) {
setError(err.message)
}
}
return (
<section className="card">
@ -30,6 +369,291 @@ export default function SettingsPage() {
))}
</ul>
)}
{error && <p className="error">{error}</p>}
<h2>Writing Profile</h2>
<p className="muted">
Stabile Hülle: Core, optionale Facets, darin dynamische semantische Traits, Evidence und Exemplare.
Die Keys chronology, humor, detail, rhythm sind nur eine erste Ordnungshilfe, kein festes Raster.
Semantische Traits entstehen durch Review, nicht durch Wortzählungen. Kontinuierliches Lernen erst nach dem bestätigten Initial Profile Build.
</p>
<p className="muted">{LIFECYCLE_LABELS[profile?.lifecycle] || LIFECYCLE_LABELS.uninitialized}</p>
{profile?.confirmed && (
<>
<label className="stack">
Governance
<select
value={profile?.governance || 'learning'}
onChange={(e) => patchWriting({ governance: e.target.value })}
>
<option value="learning">Lernen</option>
<option value="advising">Vorschlagen</option>
<option value="frozen">Eingefroren</option>
</select>
</label>
<p className="muted">{GOVERNANCE_LABELS[profile?.governance] || ''}</p>
</>
)}
<DocumentTransfer
token={session.token}
exportPath="/api/journal/writing-profile/export"
restorePath="/api/journal/writing-profile/restore"
filename="kansho-writing-profile.json"
confirmText="Writing Profile aus der Datei wiederherstellen? Vorhandene Facets, Traits und Governance werden ersetzt. Journaltexte bleiben."
onRestored={setProfile}
onError={setError}
/>
<form className="stack" onSubmit={importOwn}>
<textarea
rows={6}
value={importText}
onChange={(e) => setImportText(e.target.value)}
placeholder="Eigenen früheren Text einfügen. Für den Initial Build einen größeren Korpus nutzen, nicht nur zwei Einträge."
/>
<label className="stack">
Entstanden
<input type="date" value={importDate} onChange={(e) => setImportDate(e.target.value)} />
</label>
<label className="stack">
Quellenart
<select value={importHint} onChange={(e) => setImportHint(e.target.value)}>
{CONTEXT_HINTS.map((item) => (
<option key={item.value} value={item.value}>{item.label}</option>
))}
</select>
</label>
<button type="submit">Text in den Korpus legen</button>
</form>
<div className="stack">
<h3>{profile?.confirmed ? 'Profile Review' : 'Initial Profile Build'}</h3>
<p className="muted">
{profile?.confirmed
? 'Neue Texte werden lokal auf Drift geprüft. Eine AI-Review startet nur, wenn du sie auslöst — gebündelt, als API-Call oder als kopierbares Paket. Existing-before-New: bestehenden Trait bestätigen, präzisieren, Scope ändern, zusammenführen oder aufteilen, erst dann neu anlegen.'
: 'Sammle zuerst den historischen Korpus. Der Initial Build liest viele zeitgestempelte Quellen, nicht nur die letzten zwei Einträge. Urlaubstagebücher belegen primär das autobiografische Journal, nicht automatisch den globalen Core.'}
</p>
<p className="muted">
Version {review?.version ?? profile?.version ?? 0}
{review?.review_ready ? ' · Sammelprüfung bereit' : ''}
{(review?.pending_evidence?.length || profile?.pending_evidence) ? ` · ${review?.pending_evidence?.length ?? profile?.pending_evidence} Evidenz(en)` : ''}
</p>
{review?.pending_evidence?.length > 0 && profile?.confirmed && (
<ul className="muted">
{review.pending_evidence.map((item) => (
<li key={item.id}>{item.trigger} · {item.source_kind || 'ohne Quelle'}</li>
))}
</ul>
)}
{review?.versions?.length > 0 && (
<ul className="muted">
{review.versions.slice(0, 5).map((item) => (
<li key={item.seq}>v{item.seq} · {item.cause || 'Änderung'}</li>
))}
</ul>
)}
<div className="row-actions">
<button type="button" className="ghost" disabled={reviewBusy} onClick={copyReviewPrompt}>
{profile?.confirmed ? 'Review-Paket kopieren' : 'Initial-Paket kopieren'}
</button>
<button type="button" disabled={reviewBusy} onClick={runApiReview}>
{profile?.confirmed ? 'API-Review' : 'Initial Build (API)'}
</button>
</div>
{reviewPrompt && (
<textarea
className="writing-review-prompt"
rows={8}
readOnly
value={reviewPrompt}
/>
)}
<form className="stack" onSubmit={importReviewResult}>
<textarea
rows={6}
value={reviewResult}
onChange={(e) => setReviewResult(e.target.value)}
placeholder="Review-Ergebnis (JSON: kansho.profile_review_result) einfügen …"
/>
<button type="submit" disabled={reviewBusy || !reviewResult.trim()}>Ergebnis prüfen</button>
</form>
</div>
{profile?.suggestions?.length > 0 && (
<div className="stack">
<h3>Vorschläge</h3>
<ul className="stack">
{profile.suggestions.map((item) => (
<li key={item.id}>
<p>{item.trait_slug || item.facet_key}: {item.proposed_value}</p>
{item.evidence && <p className="muted">{item.evidence}</p>}
<button type="button" onClick={() => resolveWriting(item.id, 'accept')}>Übernehmen</button>
{' '}
<button type="button" className="ghost" onClick={() => resolveWriting(item.id, 'reject')}>Verwerfen</button>
</li>
))}
</ul>
</div>
)}
{profile?.core && (
<div className="stack">
<h3>Core</h3>
<ul className="stack facet-list">
<FacetRow item={profile.core} onSave={saveFacet} />
</ul>
</div>
)}
{(profile?.facets || []).filter((item) => item.facet_key !== 'core').length > 0 && (
<div className="stack">
<h3>Facets</h3>
<ul className="stack facet-list">
{(profile.facets || []).filter((item) => item.facet_key !== 'core').map((item) => (
<FacetRow key={item.id || item.facet_key} item={item} onSave={saveFacet} />
))}
</ul>
</div>
)}
{(profile?.traits || []).length > 0 && (
<div className="stack">
<h3>Semantische Traits</h3>
<ul className="stack facet-list">
{profile.traits.map((item) => (
<TraitRow key={item.id || item.slug} item={item} onSave={saveTrait} />
))}
</ul>
</div>
)}
{!profile?.compiled_brief && (
<p className="muted">
Noch kein Stilbrief. Sobald Einträge oder Importe mit Zeitbezug vorliegen, entstehen Exzerpte. Traits folgen erst aus dem Initial Build.
</p>
)}
{profile?.sources?.length > 0 && (
<ul className="muted">
{profile.sources.map((item) => (
<li key={item.id}>
{kindLabel(item.kind)}
{item.occurred_at ? ` · ${String(item.occurred_at).slice(0, 10)}` : ''}
{item.context_hint ? ` · ${item.context_hint}` : ''}
{item.recency_role ? ` · ${item.recency_role}` : ''}
</li>
))}
</ul>
)}
{profile?.compiled_brief && (
<div className="stack">
<h2>Aktueller Brief</h2>
<p className="muted">Abgeleitet aus bestätigten Traits und zeitgestempelten Quellen, nicht die einzige Wahrheitsquelle.</p>
<p className="writing-brief">{profile.compiled_brief}</p>
</div>
)}
<h2>Interaction Profile</h2>
<p className="muted">
{interaction?.note || 'Stabile Dialogpräferenzen, getrennt vom Writing Profile. Der aktuelle Gesprächszustand bleibt automatisch und ist kein Profil.'}
{' '}Dauerhafte Präferenzen nur aus explizitem Feedback, manuellen Einstellungen oder angenommenen Vorschlägen. Beobachtetes bleibt Candidate.
</p>
<label className="stack">
Governance
<select
value={interaction?.governance || 'advising'}
onChange={(e) => patchInteraction({ governance: e.target.value })}
>
<option value="learning">Lernen</option>
<option value="advising">Vorschlagen</option>
<option value="frozen">Eingefroren</option>
</select>
</label>
<DocumentTransfer
token={session.token}
exportPath="/api/journal/interaction-profile/export"
restorePath="/api/journal/interaction-profile/restore"
filename="kansho-interaction-profile.json"
confirmText="Interaction Profile aus der Datei wiederherstellen? Vorhandene persönliche Präferenzen und Governance werden ersetzt."
onRestored={setInteraction}
onError={setError}
/>
{interaction?.defaults?.length > 0 && (
<div className="stack">
<h3>Produktdefaults</h3>
<ul className="stack">
{interaction.defaults.map((item) => (
<li key={`default-${item.scope}-${item.pref_key}`} className="facet-row">
<div className="facet-head">
<strong>{item.title}</strong>
<span className="muted">{item.scope}{item.overridden ? ' · persönlich überschrieben' : ''}</span>
</div>
<textarea
key={`${item.scope}-${item.pref_key}-${item.value}`}
rows={2}
defaultValue={item.value}
onChange={(e) => setPrefDrafts((prev) => ({
...prev,
[`default:${item.scope}:${item.pref_key}`]: e.target.value
}))}
/>
{!item.overridden && (
<button type="button" onClick={() => saveDefaultAsPref(item)}>Als persönliche Präferenz übernehmen</button>
)}
</li>
))}
</ul>
</div>
)}
{interaction?.preferences?.length > 0 && (
<div className="stack">
<h3>Persönliche Präferenzen</h3>
<ul className="stack">
{interaction.preferences.map((item) => (
<li key={item.id} className="facet-row">
<div className="facet-head">
<strong>{item.title || item.pref_key}</strong>
<span className="muted">{item.scope} · {item.origin}</span>
</div>
<textarea
key={`${item.id}-${item.value}`}
rows={2}
defaultValue={item.value}
onChange={(e) => setPrefDrafts((prev) => ({
...prev,
[`${item.scope}:${item.pref_key}`]: e.target.value
}))}
/>
<button type="button" onClick={() => savePref(item)}>Speichern</button>
</li>
))}
</ul>
</div>
)}
{interaction?.suggestions?.length > 0 && (
<div className="stack">
<h3>Vorschläge</h3>
<ul className="stack">
{interaction.suggestions.map((item) => (
<li key={item.id}>
<p>{item.scope}/{item.pref_key}: {item.proposed_value}</p>
{item.evidence && <p className="muted">{item.evidence}</p>}
<button type="button" onClick={() => resolveInteraction(item.id, 'accept')}>Übernehmen</button>
{' '}
<button type="button" className="ghost" onClick={() => resolveInteraction(item.id, 'reject')}>Verwerfen</button>
</li>
))}
</ul>
</div>
)}
<h2>Beide Profile</h2>
<p className="muted">
Ein JSON-Dokument mit Writing Profile und Interaction Profile. Kein Journal-Backup, keine Dialoge.
</p>
<DocumentTransfer
token={session.token}
exportPath="/api/journal/profiles/export"
restorePath="/api/journal/profiles/restore"
filename="kansho-profiles.json"
confirmText="Beide Profile aus der Datei wiederherstellen? Facets, Präferenzen und Governance werden ersetzt."
onRestored={(next) => {
if (next?.writing) setProfile(next.writing)
if (next?.interaction) setInteraction(next.interaction)
}}
onError={setError}
/>
</section>
)
}