MVP 0.9
This commit is contained in:
parent
a8caeb0dd4
commit
fd7102e3b3
13
README.md
13
README.md
|
|
@ -8,7 +8,7 @@ Kanshō ist **kein** Tagebuch mit KI, keine Meditations-App und kein generischer
|
||||||
|
|
||||||
## Aktueller Stand
|
## 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 |
|
| Bereich | Stand |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|
@ -16,7 +16,7 @@ Fachliche Konzeption und technische Rahmenarchitektur plus ein **lokales Produkt
|
||||||
| Technische Form | entschieden: PWA, Mobile First, Offline, Transkription |
|
| Technische Form | entschieden: PWA, Mobile First, Offline, Transkription |
|
||||||
| Fachliche Doku | Baseline in `docs/architecture/functional/` |
|
| Fachliche Doku | Baseline in `docs/architecture/functional/` |
|
||||||
| Technische Doku | vorläufiger Rahmen in `docs/architecture/technical/` |
|
| 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)
|
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
|
```powershell
|
||||||
cd backend
|
cd backend
|
||||||
.\.venv\Scripts\python tests\test_frame.py
|
.\.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
|
## Lokal weiterarbeiten
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|
|
||||||
24
backend/.env.example
Normal file
24
backend/.env.example
Normal 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
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
|
||||||
18
backend/config/providers.seed.json
Normal file
18
backend/config/providers.seed.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
from data_layer import read
|
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:
|
def is_closing_turn(text: str) -> bool:
|
||||||
messages = read("conversation_messages", profile_id=profile_id, context={"conversation_id": conversation_id})
|
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 {
|
return {
|
||||||
|
"profile_id": profile_id,
|
||||||
"conversation_id": conversation_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,
|
"egress": False,
|
||||||
"note": "Volltext bleibt lokal. Minimierung und Egress nur über das Privacy Gateway.",
|
"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),
|
||||||
|
}
|
||||||
|
|
|
||||||
110
backend/conversation_signals.py
Normal file
110
backend/conversation_signals.py
Normal 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
|
||||||
306
backend/db.py
306
backend/db.py
|
|
@ -30,6 +30,38 @@ _PROMPT_COLUMNS = {
|
||||||
"sort_order": "INTEGER NOT NULL DEFAULT 0",
|
"sort_order": "INTEGER NOT NULL DEFAULT 0",
|
||||||
"updated": "TEXT NOT NULL DEFAULT (datetime('now'))",
|
"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:
|
def _connect() -> sqlite3.Connection:
|
||||||
|
|
@ -119,9 +151,14 @@ def _seed_prompts(conn: sqlite3.Connection) -> None:
|
||||||
"""Prompts come from JSON/DB, never from Python string literals."""
|
"""Prompts come from JSON/DB, never from Python string literals."""
|
||||||
items = json.loads(PROMPTS_SEED_PATH.read_text(encoding="utf-8"))
|
items = json.loads(PROMPTS_SEED_PATH.read_text(encoding="utf-8"))
|
||||||
for item in items:
|
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(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR IGNORE INTO ai_prompts
|
INSERT INTO ai_prompts
|
||||||
(id, slug, name, description, category, prompt_type, template,
|
(id, slug, name, description, category, prompt_type, template,
|
||||||
required_feature, is_system_default, default_template)
|
required_feature, is_system_default, default_template)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
|
||||||
|
|
@ -133,11 +170,250 @@ def _seed_prompts(conn: sqlite3.Connection) -> None:
|
||||||
item.get("description") or "",
|
item.get("description") or "",
|
||||||
item.get("category") or "uncategorized",
|
item.get("category") or "uncategorized",
|
||||||
item.get("prompt_type") or "base",
|
item.get("prompt_type") or "base",
|
||||||
item.get("template") or "",
|
template,
|
||||||
item.get("required_feature") or "ai_calls",
|
item.get("required_feature") or "ai_calls",
|
||||||
item.get("template") or "",
|
template,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
continue
|
||||||
|
untouched = (existing.get("template") or "") == (existing.get("default_template") or "")
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
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["name"],
|
||||||
|
item.get("description") or "",
|
||||||
|
item.get("category") or "uncategorized",
|
||||||
|
item.get("prompt_type") or "base",
|
||||||
|
item.get("required_feature") or "ai_calls",
|
||||||
|
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:
|
def init_db() -> None:
|
||||||
|
|
@ -146,8 +422,32 @@ def init_db() -> None:
|
||||||
conn.executescript(schema)
|
conn.executescript(schema)
|
||||||
_seed_platform(conn)
|
_seed_platform(conn)
|
||||||
_seed_prompts(conn)
|
_seed_prompts(conn)
|
||||||
|
from provider_settings import seed_provider_settings
|
||||||
|
|
||||||
|
seed_provider_settings(conn)
|
||||||
_ensure_columns(conn, "profiles", _PROFILE_COLUMNS)
|
_ensure_columns(conn, "profiles", _PROFILE_COLUMNS)
|
||||||
_ensure_columns(conn, "ai_prompts", _PROMPT_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, "001_frame")
|
||||||
_mark(conn, "002_platform")
|
_mark(conn, "002_platform")
|
||||||
_mark(conn, "003_dialogue_memory")
|
_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()
|
||||||
|
|
|
||||||
|
|
@ -81,19 +81,36 @@ def thread_snapshot(profile_id: str) -> list[dict]:
|
||||||
return [row_to_dict(row) for row in rows]
|
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())
|
conversation_id = str(uuid.uuid4())
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
if usage_session_id:
|
if usage_session_id:
|
||||||
session = _owned(conn, "usage_sessions", usage_session_id, profile_id)
|
session = _owned(conn, "usage_sessions", usage_session_id, profile_id)
|
||||||
if not session:
|
if not session:
|
||||||
raise StoreError("not_found", "Nutzungssitzung nicht gefunden", 404)
|
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(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO conversations (id, profile_id, usage_session_id, title)
|
INSERT INTO conversations
|
||||||
VALUES (?, ?, ?, ?)
|
(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())
|
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
|
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]:
|
def list_conversations(profile_id: str) -> list[dict]:
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
rows = conn.execute(
|
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())
|
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:
|
def link_thread_space(profile_id: str, thread_id: str, space_id: str, confidence: float | None = None) -> dict:
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
if not _owned(conn, "threads", thread_id, profile_id):
|
if not _owned(conn, "threads", thread_id, profile_id):
|
||||||
|
|
|
||||||
365
backend/dialogue_turn.py
Normal file
365
backend/dialogue_turn.py
Normal 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,
|
||||||
|
}
|
||||||
|
|
@ -3,11 +3,13 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import placeholder_mvp # noqa: F401
|
||||||
import placeholder_system # noqa: F401 registers system keys
|
import placeholder_system # noqa: F401 registers system keys
|
||||||
import privacy_placeholders
|
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 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):
|
class EngineError(Exception):
|
||||||
|
|
@ -18,6 +20,19 @@ class EngineError(Exception):
|
||||||
self.status_code = status_code
|
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:
|
def preview_prompt(prompt: dict, context: dict[str, Any] | None = None) -> dict:
|
||||||
template = prompt.get("template") or ""
|
template = prompt.get("template") or ""
|
||||||
if prompt.get("prompt_type") != "base":
|
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:
|
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)
|
preview = preview_prompt(prompt, context)
|
||||||
feature_id = prompt.get("required_feature") or "ai_calls"
|
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:
|
except EntitlementError as exc:
|
||||||
raise EngineError(exc.code, exc.message, exc.status_code) from exc
|
raise EngineError(exc.code, exc.message, exc.status_code) from exc
|
||||||
try:
|
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(
|
GatewayRequest(
|
||||||
prompt_id=prompt["id"],
|
prompt_id=prompt["id"],
|
||||||
purpose=purpose,
|
purpose=purpose,
|
||||||
data_class=data_class,
|
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:
|
except PrivacyGatewayError as exc:
|
||||||
raise EngineError(exc.code, exc.message, exc.status_code) from 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
216
backend/entity_detect.py
Normal 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
68
backend/env_loader.py
Normal 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
282
backend/identity_store.py
Normal 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}
|
||||||
32
backend/interaction_defaults.py
Normal file
32
backend/interaction_defaults.py
Normal 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"
|
||||||
423
backend/interaction_profile_store.py
Normal file
423
backend/interaction_profile_store.py
Normal 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
70
backend/journal_body.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
"""Journal entry body as Markdown. Media stays in media_assets; the body only references it.
|
||||||
|
|
||||||
|
Canonical embed: 
|
||||||
|
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""
|
||||||
|
|
||||||
|
|
||||||
|
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
110
backend/journal_generate.py
Normal 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
51
backend/journal_policy.py
Normal 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
186
backend/journal_shape.py
Normal 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ß", "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
474
backend/journal_store.py
Normal 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),
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
|
from env_loader import load_env_file
|
||||||
|
|
||||||
|
load_env_file()
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from db import init_db
|
from db import init_db
|
||||||
import data_layer_dialogue # noqa: F401
|
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
|
from version import APP_VERSION
|
||||||
|
|
||||||
app = FastAPI(title="Kanshō", version=APP_VERSION)
|
app = FastAPI(title="Kanshō", version=APP_VERSION)
|
||||||
|
|
@ -19,6 +23,7 @@ app.add_middleware(
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
app.include_router(dialogue.router)
|
app.include_router(dialogue.router)
|
||||||
|
app.include_router(journal.router)
|
||||||
app.include_router(prompts.router)
|
app.include_router(prompts.router)
|
||||||
app.include_router(placeholders.router)
|
app.include_router(placeholders.router)
|
||||||
app.include_router(subscription.router)
|
app.include_router(subscription.router)
|
||||||
|
|
|
||||||
208
backend/media_store.py
Normal file
208
backend/media_store.py
Normal 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}
|
||||||
82
backend/placeholder_mvp.py
Normal file
82
backend/placeholder_mvp.py
Normal 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"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -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 __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
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):
|
class PrivacyGatewayError(Exception):
|
||||||
def __init__(self, code: str, message: str, status_code: int = 503):
|
def __init__(self, code: str, message: str, status_code: int = 503):
|
||||||
|
|
@ -20,6 +31,7 @@ class GatewayRequest:
|
||||||
purpose: str
|
purpose: str
|
||||||
data_class: str
|
data_class: str
|
||||||
payload: dict[str, Any] = field(default_factory=dict)
|
payload: dict[str, Any] = field(default_factory=dict)
|
||||||
|
profile_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -35,31 +47,264 @@ ALLOWED_CLASSES = {"A", "B", "C"}
|
||||||
LOCAL_ONLY_CLASS = "A"
|
LOCAL_ONLY_CLASS = "A"
|
||||||
|
|
||||||
|
|
||||||
|
def reset_debug() -> None:
|
||||||
|
global debug_last, debug_calls
|
||||||
|
debug_last = None
|
||||||
|
debug_calls = 0
|
||||||
|
|
||||||
|
|
||||||
def inspect(request: GatewayRequest) -> GatewayResult:
|
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()
|
data_class = (request.data_class or "").upper()
|
||||||
if data_class not in ALLOWED_CLASSES:
|
if data_class not in ALLOWED_CLASSES:
|
||||||
return GatewayResult(
|
return GatewayResult(allowed=False, reason="unknown_data_class")
|
||||||
allowed=False,
|
|
||||||
reason="unknown_data_class",
|
|
||||||
)
|
|
||||||
if data_class == LOCAL_ONLY_CLASS:
|
if data_class == LOCAL_ONLY_CLASS:
|
||||||
return GatewayResult(
|
return GatewayResult(allowed=False, reason="class_a_never_leaves_local_zone")
|
||||||
allowed=False,
|
config = generate_provider()
|
||||||
reason="class_a_never_leaves_local_zone",
|
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ß", "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(
|
person = _is_person_token(token)
|
||||||
allowed=False,
|
|
||||||
reason="no_egress_provider_configured",
|
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:
|
def complete(request: GatewayRequest) -> GatewayResult:
|
||||||
|
global debug_last, debug_calls
|
||||||
result = inspect(request)
|
result = inspect(request)
|
||||||
if not result.allowed:
|
if not result.allowed:
|
||||||
raise PrivacyGatewayError(
|
raise PrivacyGatewayError(
|
||||||
result.reason,
|
result.reason,
|
||||||
"Persönlicher KI-Aufruf wurde vom Privacy Gateway blockiert. "
|
"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
|
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"),
|
||||||
|
}
|
||||||
|
|
|
||||||
97
backend/profile_documents.py
Normal file
97
backend/profile_documents.py
Normal 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
1119
backend/profile_review.py
Normal file
File diff suppressed because it is too large
Load Diff
120
backend/pronoun_bind.py
Normal file
120
backend/pronoun_bind.py
Normal 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)
|
||||||
153
backend/provider_settings.py
Normal file
153
backend/provider_settings.py
Normal 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
171
backend/providers.py
Normal 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
|
||||||
|
|
@ -2,3 +2,4 @@ fastapi==0.115.6
|
||||||
uvicorn[standard]==0.34.0
|
uvicorn[standard]==0.34.0
|
||||||
bcrypt==4.2.1
|
bcrypt==4.2.1
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
|
python-multipart==0.0.20
|
||||||
|
|
|
||||||
175
backend/retrieval.py
Normal file
175
backend/retrieval.py
Normal 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}]
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from auth import require_admin_dep
|
from auth import require_admin_dep
|
||||||
from db import get_db
|
from db import get_db
|
||||||
from dialogue_store import StoreError, get_conversation, inventory, list_conversations, list_derived_for_conversation, list_messages
|
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
|
from version import APP_VERSION, BUILD_DATE, MODULE_VERSIONS
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
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"]
|
prompts = conn.execute("SELECT COUNT(*) AS n FROM ai_prompts").fetchone()["n"]
|
||||||
features = conn.execute("SELECT COUNT(*) AS n FROM features").fetchone()["n"]
|
features = conn.execute("SELECT COUNT(*) AS n FROM features").fetchone()["n"]
|
||||||
dialogue = inventory()
|
dialogue = inventory()
|
||||||
|
providers = public_status()
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"service": "kansho",
|
"service": "kansho",
|
||||||
|
|
@ -26,6 +29,10 @@ def admin_health(session: dict = Depends(require_admin_dep)):
|
||||||
"prompts": prompts,
|
"prompts": prompts,
|
||||||
"features": features,
|
"features": features,
|
||||||
"dialogue": dialogue,
|
"dialogue": dialogue,
|
||||||
|
"providers": {
|
||||||
|
role["role"]: {"ready": role["ready"], "key_present": role["key_present"]}
|
||||||
|
for role in providers["roles"]
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"session": {
|
"session": {
|
||||||
"profile_id": session["profile_id"],
|
"profile_id": session["profile_id"],
|
||||||
|
|
@ -54,3 +61,37 @@ def admin_conversation(conversation_id: str, session: dict = Depends(require_adm
|
||||||
}
|
}
|
||||||
except StoreError as exc:
|
except StoreError as exc:
|
||||||
raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": exc.message}) from 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()
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,13 @@ from auth import require_auth
|
||||||
from context_builder import build_internal_context
|
from context_builder import build_internal_context
|
||||||
from continuity import checkpoint_usage_session, close_usage_session
|
from continuity import checkpoint_usage_session, close_usage_session
|
||||||
from derived_kinds import catalog
|
from derived_kinds import catalog
|
||||||
|
from dialogue_turn import run_turn, visible_for_role
|
||||||
|
from engine import EngineError
|
||||||
from dialogue_store import (
|
from dialogue_store import (
|
||||||
StoreError,
|
StoreError,
|
||||||
append_message,
|
append_message,
|
||||||
create_conversation,
|
create_conversation,
|
||||||
|
delete_conversation,
|
||||||
create_handoff,
|
create_handoff,
|
||||||
create_space,
|
create_space,
|
||||||
create_thread,
|
create_thread,
|
||||||
|
|
@ -27,7 +30,7 @@ from dialogue_store import (
|
||||||
router = APIRouter(prefix="/api/dialogue", tags=["dialogue"])
|
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
|
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):
|
class ConversationWrite(BaseModel):
|
||||||
usage_session_id: str | None = None
|
usage_session_id: str | None = None
|
||||||
title: str = ""
|
title: str = ""
|
||||||
|
space_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class MessageWrite(BaseModel):
|
class MessageWrite(BaseModel):
|
||||||
|
|
@ -102,7 +106,12 @@ def checkpoint_session(usage_session_id: str, session: dict = Depends(require_au
|
||||||
@router.post("/conversations")
|
@router.post("/conversations")
|
||||||
def post_conversation(req: ConversationWrite, session: dict = Depends(require_auth)):
|
def post_conversation(req: ConversationWrite, session: dict = Depends(require_auth)):
|
||||||
try:
|
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:
|
except StoreError as exc:
|
||||||
_http(exc)
|
_http(exc)
|
||||||
|
|
||||||
|
|
@ -112,6 +121,18 @@ def get_conversations(session: dict = Depends(require_auth)):
|
||||||
return list_conversations(session["profile_id"])
|
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}")
|
@router.get("/conversations/{conversation_id}")
|
||||||
def get_one_conversation(conversation_id: str, session: dict = Depends(require_auth)):
|
def get_one_conversation(conversation_id: str, session: dict = Depends(require_auth)):
|
||||||
try:
|
try:
|
||||||
|
|
@ -139,6 +160,17 @@ def post_message(conversation_id: str, req: MessageWrite, session: dict = Depend
|
||||||
_http(exc)
|
_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")
|
@router.get("/conversations/{conversation_id}/messages")
|
||||||
def get_messages(conversation_id: str, session: dict = Depends(require_auth)):
|
def get_messages(conversation_id: str, session: dict = Depends(require_auth)):
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
604
backend/routers/journal.py
Normal file
604
backend/routers/journal.py
Normal 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)
|
||||||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from auth import require_auth
|
from auth import require_auth
|
||||||
|
import placeholder_mvp # noqa: F401
|
||||||
import placeholder_system # noqa: F401
|
import placeholder_system # noqa: F401
|
||||||
from placeholders import list_placeholders
|
from placeholders import list_placeholders
|
||||||
from privacy_placeholders import catalog as privacy_catalog
|
from privacy_placeholders import catalog as privacy_catalog
|
||||||
|
|
|
||||||
|
|
@ -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())
|
current = row_to_dict(conn.execute("SELECT id FROM ai_prompts WHERE id = ?", (prompt_id,)).fetchone())
|
||||||
if not current:
|
if not current:
|
||||||
raise HTTPException(404, "Prompt nicht gefunden")
|
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(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE ai_prompts
|
UPDATE ai_prompts
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,13 @@ CREATE TABLE IF NOT EXISTS conversations (
|
||||||
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
usage_session_id TEXT REFERENCES usage_sessions(id),
|
usage_session_id TEXT REFERENCES usage_sessions(id),
|
||||||
title TEXT NOT NULL DEFAULT '',
|
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')),
|
created TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated 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')),
|
created TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
UNIQUE (profile_id, token)
|
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'))
|
||||||
|
);
|
||||||
|
|
|
||||||
266
backend/tests/test_architecture_correction.py
Normal file
266
backend/tests/test_architecture_correction.py
Normal 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()
|
||||||
|
|
@ -11,6 +11,8 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||||
sys.path.insert(0, str(ROOT))
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
os.environ["KANSHO_DB_PATH"] = str(Path(tempfile.gettempdir()) / "kansho-frame-test.sqlite")
|
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)
|
Path(os.environ["KANSHO_DB_PATH"]).unlink(missing_ok=True)
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
@ -29,7 +31,8 @@ def auth_header(token: str) -> dict:
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
seed_prompts = json.loads((ROOT / "config" / "prompts.seed.json").read_text(encoding="utf-8"))
|
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:
|
with TestClient(app) as client:
|
||||||
health = client.get("/api/health")
|
health = client.get("/api/health")
|
||||||
|
|
@ -75,7 +78,49 @@ def main() -> None:
|
||||||
|
|
||||||
admin = client.get("/api/admin/health", headers=headers)
|
admin = client.get("/api/admin/health", headers=headers)
|
||||||
expect(admin.status_code == 200, "admin health")
|
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(
|
missing = client.post(
|
||||||
"/api/prompts/execute",
|
"/api/prompts/execute",
|
||||||
|
|
@ -163,7 +208,22 @@ def main() -> None:
|
||||||
placeholders = client.get("/api/placeholders", headers=user_headers)
|
placeholders = client.get("/api/placeholders", headers=user_headers)
|
||||||
expect(placeholders.status_code == 200, "placeholder catalog")
|
expect(placeholders.status_code == 200, "placeholder catalog")
|
||||||
keys = {item["key"] for item in placeholders.json()["context"]}
|
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(
|
last_admin = client.patch(
|
||||||
f"/api/users/{me.json()['id']}",
|
f"/api/users/{me.json()['id']}",
|
||||||
|
|
|
||||||
52
backend/tests/test_journal_body.py
Normal file
52
backend/tests/test_journal_body.py
Normal 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"\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"", "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()
|
||||||
78
backend/tests/test_journal_shape.py
Normal file
78
backend/tests/test_journal_shape.py
Normal 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()
|
||||||
699
backend/tests/test_mvp_journal.py
Normal file
699
backend/tests/test_mvp_journal.py
Normal file
|
|
@ -0,0 +1,699 @@
|
||||||
|
"""MVP journal acceptance cases A–K. 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"" in embedded.json()["body"], "image token stored as markdown")
|
||||||
|
expect(f"" 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()
|
||||||
258
backend/tests/test_privacy_detect.py
Normal file
258
backend/tests/test_privacy_detect.py
Normal 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()
|
||||||
289
backend/tests/test_profile_governance.py
Normal file
289
backend/tests/test_profile_governance.py
Normal 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()
|
||||||
427
backend/tests/test_profile_review.py
Normal file
427
backend/tests/test_profile_review.py
Normal 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()
|
||||||
49
backend/tests/test_writing_profile.py
Normal file
49
backend/tests/test_writing_profile.py
Normal 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()
|
||||||
|
|
@ -10,4 +10,5 @@ MODULE_VERSIONS = {
|
||||||
"data_layer": "0.1.0",
|
"data_layer": "0.1.0",
|
||||||
"dialogue": "0.1.0",
|
"dialogue": "0.1.0",
|
||||||
"continuity": "0.1.0",
|
"continuity": "0.1.0",
|
||||||
|
"journal": "0.1.0",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
169
backend/writing_profile_infer.py
Normal file
169
backend/writing_profile_infer.py
Normal 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 ""
|
||||||
146
backend/writing_profile_schema.py
Normal file
146
backend/writing_profile_schema.py
Normal 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
|
||||||
|
]
|
||||||
1357
backend/writing_profile_store.py
Normal file
1357
backend/writing_profile_store.py
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -13,7 +13,7 @@ Dieses Dokument dient dazu, für weitere Konzeptarbeit nur die tatsächlich ben
|
||||||
|
|
||||||
## 1. Root-Dokumente
|
## 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.
|
- `fachliche_zielarchitektur.md` – Governance, Dokumentationsprinzipien, Querschnittsinvarianten, Dateistruktur.
|
||||||
- `produktvision_und_produktidentitaet.md` – Vision, Identität, Scope, Produktprinzipien und Nicht-Ziele.
|
- `produktvision_und_produktidentitaet.md` – Vision, Identität, Scope, Produktprinzipien und Nicht-Ziele.
|
||||||
- `interview_plan.md` – Kapitelplan, Interviewmethode, Fortschritt und nächster Interviewblock.
|
- `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 |
|
| `reflection_outputs.md` | Journal/Memory/Knowledge/Action Outputs |
|
||||||
| `integrations.md` | Jinkendo-Produktgrenzen und Integrationen |
|
| `integrations.md` | Jinkendo-Produktgrenzen und Integrationen |
|
||||||
| `guardrails.md` | Privacy Gateway, Pseudonymisierung, externe KI |
|
| `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
|
## 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`
|
3. `reflection_outputs.md`
|
||||||
4. das jeweils betroffene Fachkapitel
|
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
|
### Datenschutz / externe KI
|
||||||
|
|
||||||
1. `fachliche_zielarchitektur.md`
|
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 F–I führen sie weiter. Encryption, Löschen und DSFA (Phase H1) sind Ausprägung, nicht Ersatz der Invariante.
|
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 F–I 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
|
## 5. Ladeprinzip
|
||||||
|
|
||||||
> **Nicht alle Kanshō-Dokumente gleichzeitig laden.** Root-Dokumente plus die 2–4 fachlich betroffenen Dateien bilden den Standardkontext.
|
> **Nicht alle Kanshō-Dokumente gleichzeitig laden.** Root-Dokumente plus die 2–4 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.
|
Vor größeren Querschnittsentscheidungen wird zusätzlich über `migration_mapping.md` und die relevanten Invarianten geprüft, ob andere Kapitel betroffen sind.
|
||||||
|
|
|
||||||
|
|
@ -262,8 +262,13 @@ reflection_outputs.md
|
||||||
integrations.md
|
integrations.md
|
||||||
guardrails.md
|
guardrails.md
|
||||||
migration_mapping.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 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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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**.
|
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:
|
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,
|
- bereits getroffene Entscheidungen erneut diskutiert werden,
|
||||||
|
|
|
||||||
229
docs/architecture/functional/implementation_foundation.md
Normal file
229
docs/architecture/functional/implementation_foundation.md
Normal 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.
|
||||||
951
docs/architecture/functional/mvp.md
Normal file
951
docs/architecture/functional/mvp.md
Normal 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 ``. 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.
|
||||||
567
docs/architecture/functional/mvp_stand_und_abgleich.md
Normal file
567
docs/architecture/functional/mvp_stand_und_abgleich.md
Normal 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.1–9.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` §1–2)
|
||||||
|
|
||||||
|
| 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` §5–7)
|
||||||
|
|
||||||
|
| 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.4–5.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.1–13.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.4–5.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 22–24.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
|
@ -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.
|
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
|
## 5. Entscheidungsstand
|
||||||
|
|
||||||
| Thema | Stand | Status |
|
| Thema | Stand | Status |
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,32 @@ Konkreter Pfad: offen, Muster übernommen.
|
||||||
| OpenAPI als dokumentierter Vertrag | später | offen |
|
| OpenAPI als dokumentierter Vertrag | später | offen |
|
||||||
| Interne service-Schicht vs. fette Router | service-Schicht | bevorzugte Richtung |
|
| 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 ``
|
||||||
|
- 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
|
## 6. Offene Fragen
|
||||||
|
|
||||||
1. Internes API für mindnet/Kairo: synchron REST vs. Events? → `integrations_technical.md`
|
1. Internes API für mindnet/Kairo: synchron REST vs. Events? → `integrations_technical.md`
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
## 1. Root-Dokumente
|
||||||
|
|
||||||
Technisch:
|
Technisch:
|
||||||
|
|
@ -41,6 +43,7 @@ Fachlich zusätzlich immer bei Querschnittsentscheidungen:
|
||||||
| `admin_diagnostics.md` | Diagnoseansicht |
|
| `admin_diagnostics.md` | Diagnoseansicht |
|
||||||
| `voice_and_media.md` | Sprache und Transkription |
|
| `voice_and_media.md` | Sprache und Transkription |
|
||||||
| `security.md` | Security-Baseline |
|
| `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
|
## 3. Empfohlene Context Bundles
|
||||||
|
|
||||||
|
|
@ -97,6 +100,21 @@ Fachlich zusätzlich immer bei Querschnittsentscheidungen:
|
||||||
3. fachlich: `../functional/fachliche_zielarchitektur.md` §2.7
|
3. fachlich: `../functional/fachliche_zielarchitektur.md` §2.7
|
||||||
4. `../functional/reflection_spaces.md` (Admin-/Developer View)
|
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.1–9.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
|
### Voice / PWA-Offline
|
||||||
|
|
||||||
1. `frontend_pwa_shell.md`
|
1. `frontend_pwa_shell.md`
|
||||||
|
|
@ -113,5 +131,5 @@ Vor Vereinfachungen die Invariantenliste in `technische_zielarchitektur.md` §3.
|
||||||
|
|
||||||
## 5. Code-Gerüst
|
## 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,26 @@ Business-Logik bleibt im Backend (`backend_and_api.md`).
|
||||||
| Kanshō-Startroute / endgültige IA | folgt späterem Fachstand | offen |
|
| Kanshō-Startroute / endgültige IA | folgt späterem Fachstand | offen |
|
||||||
| Desktop-Mehrspalten | späterer Fach-/UX-Stand | 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
|
## 7. Offene Fragen
|
||||||
|
|
||||||
1. Welche Platzhalter-Routen bleiben in der ersten Rahmen-Kopie sichtbar, welche werden ausgeblendet, bis Fachseiten existieren?
|
1. Welche Platzhalter-Routen bleiben in der ersten Rahmen-Kopie sichtbar, welche werden ausgeblendet, bis Fachseiten existieren?
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
## 7. Offene Fragen
|
||||||
|
|
||||||
1. IndexedDB vs. SQLite-WASM vs. nur Service-Worker-Queue?
|
1. IndexedDB vs. SQLite-WASM vs. nur Service-Worker-Queue?
|
||||||
|
|
|
||||||
323
docs/architecture/technical/mvp_implementation.md
Normal file
323
docs/architecture/technical/mvp_implementation.md
Normal 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 `` |
|
||||||
|
| `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.1–6.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.1–9.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`
|
||||||
|
|
@ -109,7 +109,7 @@ Fachliche Reflexions-Workflows (Tagesreflexion, Journal-Entwurf, …) werden **s
|
||||||
- Debug-Responses mit Klarnamen oder vollem Internal Context.
|
- Debug-Responses mit Klarnamen oder vollem Internal Context.
|
||||||
- Vermischung von `{{key}}` (Kontext) und `[[ROLE]]` (Privacy).
|
- 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)
|
- Feature-Definitionen und Limits (wenn Entitlements genutzt werden)
|
||||||
- UI-Katalog: welche Karten/Nav-Beiträge sichtbar, Layout-Overrides
|
- UI-Katalog: welche Karten/Nav-Beiträge sichtbar, Layout-Overrides
|
||||||
- System-Prompts vs. Instanz-Anpassungen (Reset auf Default)
|
- 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
|
- später: Privacy-Profile (Strict/Balanced), sobald fachlich konkret
|
||||||
|
|
||||||
### 4.2 Code / Env (nicht pro Klick im Admin)
|
### 4.2 Code / Env (nicht pro Klick im Admin)
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,55 @@ Interview H1 (Encryption at rest, vollständiges Löschen, Portabilität, DSFA b
|
||||||
| OpenRouter | Kandidat, nicht Gateway-Ersatz | entschieden |
|
| OpenRouter | Kandidat, nicht Gateway-Ersatz | entschieden |
|
||||||
| EU-Routing | bevorzugt | offen in Ausprägung |
|
| EU-Routing | bevorzugt | offen in Ausprägung |
|
||||||
| Encryption at rest, Delete, DSFA | Phase H | offen |
|
| 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
|
## 10. Offene Fragen
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,8 @@ Krisengrenzen und medizinische Hochrisikothemen sind **nicht** dieses Kapitel (`
|
||||||
2. Backup-Verschlüsselung und Schlüsselhaltung.
|
2. Backup-Verschlüsselung und Schlüsselhaltung.
|
||||||
3. DSFA und Retention, sobald Mehrbenutzer über den privaten Betrieb hinausgeht.
|
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
|
## 8. Querverweise
|
||||||
|
|
||||||
- `auth_identity_and_roles.md`, `privacy_gateway.md`, `runtime_and_deploy.md`
|
- `auth_identity_and_roles.md`, `privacy_gateway.md`, `runtime_and_deploy.md`
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
## 3. Dokumentationsprinzipien
|
||||||
|
|
@ -188,6 +190,7 @@ integrations_technical.md
|
||||||
admin_diagnostics.md
|
admin_diagnostics.md
|
||||||
voice_and_media.md
|
voice_and_media.md
|
||||||
security.md
|
security.md
|
||||||
|
mvp_implementation.md
|
||||||
```
|
```
|
||||||
|
|
||||||
| Datei | Kanonisches Thema | Reife v0.1 |
|
| Datei | Kanonisches Thema | Reife v0.1 |
|
||||||
|
|
@ -206,6 +209,7 @@ security.md
|
||||||
| `admin_diagnostics.md` | Diagnoseansicht | Mitai-Admin-Realm; Kanshō-Inhalte später |
|
| `admin_diagnostics.md` | Diagnoseansicht | Mitai-Admin-Realm; Kanshō-Inhalte später |
|
||||||
| `voice_and_media.md` | Sprache / Transkription | Bedarf im Fachstand, Ausprägung offen |
|
| `voice_and_media.md` | Sprache / Transkription | Bedarf im Fachstand, Ausprägung offen |
|
||||||
| `security.md` | Rate Limit, Secrets, Encryption | Teil entschieden, Rest 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,12 @@ Nicht entschieden, daher kein Implementierungszwang in v0.1:
|
||||||
- Fail Closed: kein unsicherer Cloud-Speech-Fallback.
|
- 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“.
|
- 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
|
## 5. Entscheidungsstand
|
||||||
|
|
||||||
| Thema | Stand | Status |
|
| Thema | Stand | Status |
|
||||||
|
|
|
||||||
733
frontend/package-lock.json
generated
733
frontend/package-lock.json
generated
|
|
@ -8,10 +8,17 @@
|
||||||
"name": "kansho",
|
"name": "kansho",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"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",
|
"lucide-react": "^0.383.0",
|
||||||
|
"marked": "^18.0.10",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
|
@ -67,7 +74,6 @@
|
||||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.7",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.7",
|
"@babel/generator": "^7.29.7",
|
||||||
|
|
@ -2002,6 +2008,34 @@
|
||||||
"node": ">=12"
|
"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": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "9.0.0",
|
"version": "9.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
|
||||||
|
|
@ -2073,6 +2107,12 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||||
|
|
@ -2576,6 +2616,449 @@
|
||||||
"win32"
|
"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": {
|
"node_modules/@trickfilm400/rollup-plugin-off-main-thread": {
|
||||||
"version": "3.0.0-pre1",
|
"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",
|
"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,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/resolve": {
|
||||||
"version": "1.20.2",
|
"version": "1.20.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
||||||
|
|
@ -2658,6 +3161,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"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==",
|
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-uri": "^3.0.1",
|
||||||
|
|
@ -2890,7 +3398,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.11.12",
|
"baseline-browser-mapping": "^2.11.12",
|
||||||
"caniuse-lite": "^1.0.30001809",
|
"caniuse-lite": "^1.0.30001809",
|
||||||
|
|
@ -3049,6 +3556,13 @@
|
||||||
"node": ">=8"
|
"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": {
|
"node_modules/data-view-buffer": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
||||||
|
|
@ -3449,6 +3963,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fast-json-stable-stringify": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||||
|
|
@ -4426,6 +4949,12 @@
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/lodash.debounce": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||||
|
|
@ -4474,6 +5003,18 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
|
@ -4590,6 +5131,12 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/own-keys": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz",
|
||||||
|
|
@ -4673,7 +5220,6 @@
|
||||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -4733,12 +5279,150 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
|
|
@ -4751,7 +5435,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
|
|
@ -4982,6 +5665,12 @@
|
||||||
"fsevents": "~2.3.2"
|
"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": {
|
"node_modules/safe-array-concat": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz",
|
"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==",
|
"integrity": "sha512-CN9BVxWhgS/hRxtUMjtC2uRWSTcSfQFHMDWma6sKKfIivCD91sM+FOPfvwoaRMqCSrUpe1nv3jDamd9eEQ4y+w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/source-map": "^0.3.3",
|
"@jridgewell/source-map": "^0.3.3",
|
||||||
"acorn": "^8.15.0",
|
"acorn": "^8.15.0",
|
||||||
|
|
@ -5484,6 +6172,19 @@
|
||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"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": {
|
"node_modules/type-fest": {
|
||||||
"version": "0.16.0",
|
"version": "0.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
|
||||||
|
|
@ -5703,13 +6404,21 @@
|
||||||
"browserslist": ">= 4.21.0"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,17 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"lucide-react": "^0.383.0",
|
||||||
|
"marked": "^18.0.10",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,12 @@ import { getMainNavItems } from './config/appNav.js'
|
||||||
import LoginScreen from './pages/LoginScreen.jsx'
|
import LoginScreen from './pages/LoginScreen.jsx'
|
||||||
import SetupScreen from './pages/SetupScreen.jsx'
|
import SetupScreen from './pages/SetupScreen.jsx'
|
||||||
import HomePage from './pages/HomePage.jsx'
|
import HomePage from './pages/HomePage.jsx'
|
||||||
import PlaceholderPage from './pages/PlaceholderPage.jsx'
|
|
||||||
import DialoguePage from './pages/DialoguePage.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 SettingsPage from './pages/SettingsPage.jsx'
|
||||||
import AdminHomePage from './pages/AdminHomePage.jsx'
|
import AdminHomePage from './pages/AdminHomePage.jsx'
|
||||||
import AdminUsersPage from './pages/AdminUsersPage.jsx'
|
import AdminUsersPage from './pages/AdminUsersPage.jsx'
|
||||||
|
|
@ -14,11 +18,14 @@ import AdminPromptsPage from './pages/AdminPromptsPage.jsx'
|
||||||
import AdminPlaceholdersPage from './pages/AdminPlaceholdersPage.jsx'
|
import AdminPlaceholdersPage from './pages/AdminPlaceholdersPage.jsx'
|
||||||
import AdminFeaturesPage from './pages/AdminFeaturesPage.jsx'
|
import AdminFeaturesPage from './pages/AdminFeaturesPage.jsx'
|
||||||
import AdminDialoguePage from './pages/AdminDialoguePage.jsx'
|
import AdminDialoguePage from './pages/AdminDialoguePage.jsx'
|
||||||
|
import AdminProvidersPage from './pages/AdminProvidersPage.jsx'
|
||||||
import RequireAdmin from './layouts/RequireAdmin.jsx'
|
import RequireAdmin from './layouts/RequireAdmin.jsx'
|
||||||
import AdminShell from './layouts/AdminShell.jsx'
|
import AdminShell from './layouts/AdminShell.jsx'
|
||||||
|
import { UnsavedProvider, useUnsavedChanges } from './context/UnsavedChanges.jsx'
|
||||||
|
|
||||||
function navItemActive(pathname, item, routerIsActive) {
|
function navItemActive(pathname, item, routerIsActive) {
|
||||||
if (item.to.startsWith('/admin')) return pathname.startsWith('/admin')
|
if (item.to.startsWith('/admin')) return pathname.startsWith('/admin')
|
||||||
|
if (item.to === '/journal') return pathname.startsWith('/journal')
|
||||||
return routerIsActive
|
return routerIsActive
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,13 +90,18 @@ function DesktopSidebar({ isAdmin, onLogout, name }) {
|
||||||
|
|
||||||
function AppShell() {
|
function AppShell() {
|
||||||
const { session, isAdmin, logout } = useAuth()
|
const { session, isAdmin, logout } = useAuth()
|
||||||
|
const { confirmLeave } = useUnsavedChanges()
|
||||||
|
const onLogout = () => {
|
||||||
|
if (!confirmLeave()) return
|
||||||
|
logout()
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<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">
|
<div className="app-main">
|
||||||
<header className="mobile-header">
|
<header className="mobile-header">
|
||||||
<strong>Kanshō</strong>
|
<strong>Kanshō</strong>
|
||||||
<button type="button" className="ghost" onClick={logout}>Abmelden</button>
|
<button type="button" className="ghost" onClick={onLogout}>Abmelden</button>
|
||||||
</header>
|
</header>
|
||||||
<main className="page">
|
<main className="page">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|
@ -114,27 +126,31 @@ export default function App() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<UnsavedProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AppShell />}>
|
<Route element={<AppShell />}>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/dialog" element={<DialoguePage />} />
|
<Route path="/journal" element={<JournalSpacesPage />} />
|
||||||
<Route
|
<Route path="/journal/:spaceId" element={<JournalSpacePage />} />
|
||||||
path="/journal"
|
<Route path="/journal/:spaceId/:dayId" element={<JournalDayPage />} />
|
||||||
element={<PlaceholderPage title="Journal" note="Journaling ist Werkzeug, nicht Produktkern. Die Inhalte folgen dem Fachkonzept." />}
|
<Route path="/journal/:spaceId/:dayId/entry" element={<JournalEditorPage />} />
|
||||||
/>
|
<Route path="/journal/:spaceId/:dayId/source/:conversationId" element={<JournalSourcePage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route element={<RequireAdmin />}>
|
<Route element={<RequireAdmin />}>
|
||||||
|
<Route path="/dialog" element={<DialoguePage />} />
|
||||||
<Route path="/admin" element={<AdminShell />}>
|
<Route path="/admin" element={<AdminShell />}>
|
||||||
<Route index element={<AdminHomePage />} />
|
<Route index element={<AdminHomePage />} />
|
||||||
<Route path="users" element={<AdminUsersPage />} />
|
<Route path="users" element={<AdminUsersPage />} />
|
||||||
<Route path="prompts" element={<AdminPromptsPage />} />
|
<Route path="prompts" element={<AdminPromptsPage />} />
|
||||||
<Route path="placeholders" element={<AdminPlaceholdersPage />} />
|
<Route path="placeholders" element={<AdminPlaceholdersPage />} />
|
||||||
<Route path="features" element={<AdminFeaturesPage />} />
|
<Route path="features" element={<AdminFeaturesPage />} />
|
||||||
|
<Route path="providers" element={<AdminProvidersPage />} />
|
||||||
<Route path="dialogue" element={<AdminDialoguePage />} />
|
<Route path="dialogue" element={<AdminDialoguePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</UnsavedProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,3 +18,26 @@ export async function api(path, { token, method = 'GET', body } = {}) {
|
||||||
}
|
}
|
||||||
return data
|
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())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,43 +8,63 @@
|
||||||
--danger: #9b3d3d;
|
--danger: #9b3d3d;
|
||||||
--nav-h: 64px;
|
--nav-h: 64px;
|
||||||
--sidebar-w: 220px;
|
--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; }
|
* { 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 {
|
body {
|
||||||
font-family: "Segoe UI", system-ui, sans-serif;
|
font-family: "Segoe UI", system-ui, sans-serif;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--ink);
|
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; }
|
p { line-height: 1.5; }
|
||||||
.muted { color: var(--muted); }
|
.muted, .lede { color: var(--muted); }
|
||||||
|
.lede { font-size: 0.9rem; }
|
||||||
.error { color: var(--danger); }
|
.error { color: var(--danger); }
|
||||||
.centered { min-height: 100vh; display: grid; place-items: center; }
|
.centered { min-height: 100dvh; display: grid; place-items: center; }
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 1.25rem;
|
padding: 1rem;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-screen {
|
.auth-screen {
|
||||||
min-height: 100vh;
|
min-height: 100dvh;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
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; }
|
.auth-screen .card { width: min(420px, 100%); display: grid; gap: 0.8rem; }
|
||||||
|
|
||||||
label { display: grid; gap: 0.35rem; font-size: 0.9rem; }
|
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: 1px solid var(--line);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 0.7rem 0.8rem;
|
padding: 0.7rem 0.8rem;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
button {
|
button {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
|
|
@ -54,6 +74,7 @@ button {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
min-height: 2.75rem;
|
||||||
}
|
}
|
||||||
button.ghost {
|
button.ghost {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
@ -62,29 +83,46 @@ button.ghost {
|
||||||
}
|
}
|
||||||
button:disabled { opacity: 0.6; }
|
button:disabled { opacity: 0.6; }
|
||||||
|
|
||||||
.app-shell { min-height: 100vh; }
|
.app-shell { min-height: 100dvh; width: 100%; max-width: 100%; }
|
||||||
.app-main { min-height: 100vh; display: flex; flex-direction: column; }
|
.app-main {
|
||||||
|
min-height: 100dvh;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
.mobile-header {
|
.mobile-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
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);
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--card);
|
||||||
}
|
}
|
||||||
.page {
|
.page {
|
||||||
flex: 1;
|
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 {
|
.bottom-nav {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0; right: 0; bottom: 0;
|
left: 0;
|
||||||
height: var(--nav-h);
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 20;
|
||||||
|
height: calc(var(--nav-h) + var(--safe-bottom));
|
||||||
|
padding-bottom: var(--safe-bottom);
|
||||||
display: flex;
|
display: flex;
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
.nav-item {
|
.nav-item {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -92,21 +130,20 @@ button:disabled { opacity: 0.6; }
|
||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
text-decoration: none;
|
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); }
|
.nav-item.active { color: var(--accent); }
|
||||||
.desktop-sidebar { display: none; }
|
.desktop-sidebar { display: none; }
|
||||||
.meta { display: grid; grid-template-columns: 8rem 1fr; gap: 0.4rem 1rem; }
|
|
||||||
.meta dt { color: var(--muted); }
|
.meta { display: grid; grid-template-columns: 1fr; gap: 0.15rem 1rem; }
|
||||||
.meta dd { margin: 0; }
|
.meta dt { color: var(--muted); font-size: 0.85rem; }
|
||||||
h2 { font-size: 1.1rem; margin: 1.2rem 0 0.5rem; }
|
.meta dd { margin: 0 0 0.55rem; }
|
||||||
textarea, select {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 0.7rem 0.8rem;
|
|
||||||
font: inherit;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.stack { display: grid; gap: 0.7rem; margin-top: 1rem; }
|
.stack { display: grid; gap: 0.7rem; margin-top: 1rem; }
|
||||||
.admin-subnav {
|
.admin-subnav {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -119,31 +156,305 @@ textarea, select {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.3rem 0.75rem;
|
padding: 0.4rem 0.75rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.admin-link.active { color: var(--accent); border-color: var(--accent); }
|
.admin-link.active { color: var(--accent); border-color: var(--accent); }
|
||||||
table.plain { width: 100%; border-collapse: collapse; font-size: 0.95rem; }
|
.table-scroll {
|
||||||
table.plain th, table.plain td { text-align: left; padding: 0.45rem 0.3rem; border-bottom: 1px solid var(--line); }
|
overflow-x: auto;
|
||||||
.row-item { display: flex; justify-content: space-between; gap: 0.8rem; align-items: center; list-style: none; }
|
max-width: 100%;
|
||||||
.row-actions { display: flex; gap: 0.3rem; }
|
-webkit-overflow-scrolling: touch;
|
||||||
ul.stack { padding: 0; }
|
|
||||||
.dialogue-layout { display: grid; gap: 1rem; margin-top: 1rem; }
|
|
||||||
@media (min-width: 720px) {
|
|
||||||
.dialogue-layout { grid-template-columns: 14rem 1fr; }
|
|
||||||
}
|
}
|
||||||
.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); }
|
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) {
|
@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 {
|
.desktop-sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
padding: 1.2rem 0.8rem;
|
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 .brand { font-weight: 700; padding: 0 0.6rem 1rem; }
|
||||||
.desktop-sidebar nav { display: grid; gap: 0.2rem; }
|
.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; }
|
.desktop-sidebar .nav-item.active { background: #eef3ef; }
|
||||||
.sidebar-footer { margin-top: auto; display: grid; gap: 0.4rem; padding: 0.6rem; }
|
.sidebar-footer { margin-top: auto; display: grid; gap: 0.4rem; padding: 0.6rem; }
|
||||||
.mobile-header, .bottom-nav { display: none; }
|
.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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
81
frontend/src/components/CallTrace.jsx
Normal file
81
frontend/src/components/CallTrace.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
115
frontend/src/components/DayScratch.jsx
Normal file
115
frontend/src/components/DayScratch.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
223
frontend/src/components/JournalDocumentEditor.jsx
Normal file
223
frontend/src/components/JournalDocumentEditor.jsx
Normal 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
|
||||||
|
|
@ -5,6 +5,7 @@ export function getAdminNavItems() {
|
||||||
{ to: '/admin/prompts', label: 'Prompts' },
|
{ to: '/admin/prompts', label: 'Prompts' },
|
||||||
{ to: '/admin/placeholders', label: 'Platzhalter' },
|
{ to: '/admin/placeholders', label: 'Platzhalter' },
|
||||||
{ to: '/admin/features', label: 'Kontingente' },
|
{ to: '/admin/features', label: 'Kontingente' },
|
||||||
|
{ to: '/admin/providers', label: 'Schnittstellen' },
|
||||||
{ to: '/admin/dialogue', label: 'Dialog' }
|
{ to: '/admin/dialogue', label: 'Dialog' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
export function getMainNavItems(isAdmin) {
|
||||||
const items = [
|
const items = [
|
||||||
{ to: '/', label: 'Start', icon: Home },
|
{ to: '/', label: 'Start', icon: Home },
|
||||||
{ to: '/dialog', label: 'Dialog', icon: MessageCircle },
|
|
||||||
{ to: '/journal', label: 'Journal', icon: BookOpen },
|
{ to: '/journal', label: 'Journal', icon: BookOpen },
|
||||||
{ to: '/settings', label: 'Einstellungen', icon: Settings }
|
{ to: '/settings', label: 'Einstellungen', icon: Settings }
|
||||||
]
|
]
|
||||||
|
|
|
||||||
72
frontend/src/context/UnsavedChanges.jsx
Normal file
72
frontend/src/context/UnsavedChanges.jsx
Normal 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)
|
||||||
|
}
|
||||||
117
frontend/src/journal/KanshoMedia.jsx
Normal file
117
frontend/src/journal/KanshoMedia.jsx
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
247
frontend/src/journal/document.js
Normal file
247
frontend/src/journal/document.js
Normal 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 ``
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
36
frontend/src/journal/document.test.js
Normal file
36
frontend/src/journal/document.test.js
Normal 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\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 = ``
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
@ -58,7 +58,7 @@ export default function AdminDialoguePage() {
|
||||||
<pre className="code">{JSON.stringify(detail.derived_records, null, 2)}</pre>
|
<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>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ export default function AdminFeaturesPage() {
|
||||||
{sub && (
|
{sub && (
|
||||||
<p>Aktueller Rahmen-Tier: <strong>{sub.tier?.name}</strong> ({sub.tier?.id}). Billing: {sub.billing ? 'aktiv' : 'keine Zahlungsanbindung'}.</p>
|
<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">
|
<table className="plain">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Feature</th><th>Typ</th><th>Reset</th><th>Kategorie</th></tr>
|
<tr><th>Feature</th><th>Typ</th><th>Reset</th><th>Kategorie</th></tr>
|
||||||
|
|
@ -43,6 +44,7 @@ export default function AdminFeaturesPage() {
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,12 @@ export default function AdminHomePage() {
|
||||||
<dd>{health.inventory?.dialogue?.messages ?? 0}</dd>
|
<dd>{health.inventory?.dialogue?.messages ?? 0}</dd>
|
||||||
<dt>Derived</dt>
|
<dt>Derived</dt>
|
||||||
<dd>{health.inventory?.dialogue?.derived_records ?? 0}</dd>
|
<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>
|
</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>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { api } from '../api.js'
|
||||||
import { useAuth } from '../context/AuthContext.jsx'
|
import { useAuth } from '../context/AuthContext.jsx'
|
||||||
|
|
||||||
const emptyForm = {
|
const emptyForm = {
|
||||||
|
id: '',
|
||||||
slug: '',
|
slug: '',
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
|
@ -10,7 +11,8 @@ const emptyForm = {
|
||||||
prompt_type: 'base',
|
prompt_type: 'base',
|
||||||
template: '',
|
template: '',
|
||||||
required_feature: 'ai_calls',
|
required_feature: 'ai_calls',
|
||||||
active: true
|
active: true,
|
||||||
|
is_system_default: false
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminPromptsPage() {
|
export default function AdminPromptsPage() {
|
||||||
|
|
@ -25,13 +27,47 @@ export default function AdminPromptsPage() {
|
||||||
|
|
||||||
useEffect(() => { load() }, [session.token])
|
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) => {
|
const save = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
setNotice('')
|
setNotice('')
|
||||||
try {
|
try {
|
||||||
await api('/api/prompts', { token: session.token, method: 'POST', body: form })
|
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)
|
setForm(emptyForm)
|
||||||
|
setNotice('Prompt angelegt.')
|
||||||
|
}
|
||||||
setPreview(null)
|
setPreview(null)
|
||||||
await load()
|
await load()
|
||||||
} catch (err) {
|
} 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) => {
|
const runPreview = async (prompt) => {
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
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 (
|
return (
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h1>Prompts</h1>
|
<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>}
|
{error && <p className="error">{error}</p>}
|
||||||
{notice && <p className="muted">{notice}</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">
|
<ul className="stack">
|
||||||
{prompts.map((prompt) => (
|
{prompts.map((prompt) => (
|
||||||
<li key={prompt.id} className="row-item">
|
<li key={prompt.id} className="row-item">
|
||||||
<div>
|
<div>
|
||||||
<strong>{prompt.name}</strong>
|
<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>
|
||||||
<div className="row-actions">
|
<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={() => runPreview(prompt)}>Preview</button>
|
||||||
<button type="button" className="ghost" onClick={() => runExecute(prompt)}>Execute</button>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
{preview && <pre className="code">{JSON.stringify(preview, null, 2)}</pre>}
|
{preview && <pre className="code">{JSON.stringify(preview, null, 2)}</pre>}
|
||||||
<form className="stack" onSubmit={save}>
|
<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>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>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>
|
<label>
|
||||||
Template
|
Template
|
||||||
<textarea
|
<textarea
|
||||||
rows={8}
|
rows={14}
|
||||||
value={form.template}
|
value={form.template}
|
||||||
onChange={(e) => setForm({ ...form, template: e.target.value })}
|
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>
|
</label>
|
||||||
|
<div className="row-actions">
|
||||||
<button type="submit">Speichern</button>
|
<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>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
171
frontend/src/pages/AdminProvidersPage.jsx
Normal file
171
frontend/src/pages/AdminProvidersPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -45,6 +45,7 @@ export default function AdminUsersPage() {
|
||||||
<h1>Nutzer</h1>
|
<h1>Nutzer</h1>
|
||||||
<p className="muted">Ein Login ist ein Profil. Keine Profilumschaltung, kein Vereinsmodell.</p>
|
<p className="muted">Ein Login ist ein Profil. Keine Profilumschaltung, kein Vereinsmodell.</p>
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
|
<div className="table-scroll">
|
||||||
<table className="plain">
|
<table className="plain">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Name</th><th>E-Mail</th><th>Rolle</th><th>Status</th><th /></tr>
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
<form className="stack" onSubmit={create}>
|
<form className="stack" onSubmit={create}>
|
||||||
<h2>Nutzer anlegen</h2>
|
<h2>Nutzer anlegen</h2>
|
||||||
<label>Name <input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /></label>
|
<label>Name <input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /></label>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,32 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { api } from '../api.js'
|
import { api } from '../api.js'
|
||||||
|
import CallTrace from '../components/CallTrace.jsx'
|
||||||
import { useAuth } from '../context/AuthContext.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() {
|
export default function DialoguePage() {
|
||||||
const { session } = useAuth()
|
const { session, isAdmin } = useAuth()
|
||||||
const [conversations, setConversations] = useState([])
|
const [conversations, setConversations] = useState([])
|
||||||
const [activeId, setActiveId] = useState(null)
|
const [activeId, setActiveId] = useState(null)
|
||||||
const [messages, setMessages] = useState([])
|
const [messages, setMessages] = useState([])
|
||||||
const [draft, setDraft] = useState('')
|
const [draft, setDraft] = useState('')
|
||||||
const [error, setError] = 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 = () =>
|
const loadList = () =>
|
||||||
api('/api/dialogue/conversations', { token: session.token })
|
api('/api/dialogue/conversations', { token: session.token })
|
||||||
.then(setConversations)
|
.then((items) => {
|
||||||
|
setConversations(items)
|
||||||
|
return items
|
||||||
|
})
|
||||||
.catch((e) => setError(e.message))
|
.catch((e) => setError(e.message))
|
||||||
|
|
||||||
const openConversation = async (id) => {
|
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 () => {
|
const start = async () => {
|
||||||
setError('')
|
setError('')
|
||||||
|
|
@ -44,56 +63,109 @@ export default function DialoguePage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const send = async (e) => {
|
const removeConversation = async () => {
|
||||||
e.preventDefault()
|
if (!activeId) return
|
||||||
if (!activeId || !draft.trim()) return
|
if (!window.confirm('Dieses Gespräch und seine Nachrichten unwiderruflich löschen?')) return
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
await api(`/api/dialogue/conversations/${activeId}/messages`, {
|
await api(`/api/dialogue/conversations/${activeId}`, { token: session.token, method: 'DELETE' })
|
||||||
token: session.token,
|
setDecision(null)
|
||||||
method: 'POST',
|
setTrace(null)
|
||||||
body: { body: draft, role: 'user' }
|
const items = await loadList()
|
||||||
})
|
const next = items?.find((item) => item.id !== activeId) || items?.[0]
|
||||||
setDraft('')
|
if (next) await openConversation(next.id)
|
||||||
await openConversation(activeId)
|
else {
|
||||||
await loadList()
|
setActiveId(null)
|
||||||
|
setMessages([])
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
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 (
|
return (
|
||||||
<section className="card">
|
<section className="card dialogue-page">
|
||||||
<h1>Dialog</h1>
|
<header className="dialogue-head">
|
||||||
<p className="muted">
|
<p className="dialogue-crumb">
|
||||||
Nur Persistenz der Originalnachrichten. Keine intelligente Einstiegs-IA, keine automatischen Fäden, kein LLM-Summary.
|
Dev/Test-Harness · kein produktiver Gesprächsraum · Journal-Tage unter <Link to="/journal">Journal</Link>
|
||||||
</p>
|
</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>}
|
{error && <p className="error">{error}</p>}
|
||||||
<button type="button" onClick={start}>Neues Gespräch</button>
|
<div className="dialogue-toolbar">
|
||||||
<div className="dialogue-layout">
|
<div className="dialogue-switcher">
|
||||||
<ul className="stack">
|
{conversations.map((item, index) => (
|
||||||
{conversations.map((item) => (
|
<button
|
||||||
<li key={item.id}>
|
key={item.id}
|
||||||
<button type="button" className={item.id === activeId ? 'ghost active' : 'ghost'} onClick={() => openConversation(item.id)}>
|
type="button"
|
||||||
{item.title || 'Gespräch'} · {item.message_count} Nachrichten
|
className={item.id === activeId ? 'ghost active' : 'ghost'}
|
||||||
|
onClick={() => openConversation(item.id)}
|
||||||
|
>
|
||||||
|
{convLabel(item, index)}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
{activeId && (
|
|
||||||
<div>
|
|
||||||
<div className="message-list">
|
|
||||||
{messages.map((item) => (
|
|
||||||
<p key={item.id}><strong>{item.role}:</strong> {item.body}</p>
|
|
||||||
))}
|
))}
|
||||||
|
<button type="button" className="ghost" onClick={start}>Neues Gespräch</button>
|
||||||
</div>
|
</div>
|
||||||
<form className="stack" onSubmit={send}>
|
{activeId && (
|
||||||
<textarea rows={4} value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Originaltext speichern …" />
|
<div className="row-actions dialogue-tools">
|
||||||
<button type="submit">Speichern</button>
|
<button type="button" className="ghost" onClick={removeConversation} disabled={busy}>
|
||||||
</form>
|
Löschen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h1>Start</h1>
|
<h1>Start</h1>
|
||||||
<p>
|
<p>
|
||||||
Der Produktrahmen läuft. Die Startlogik bleibt fachlich offen: Kontinuität des Dialogs
|
Der Einstieg bleibt bewusst dünn: kein Funktions-Dashboard. Der MVP-Kern liegt im Journal.
|
||||||
statt Funktions-Dashboard.
|
|
||||||
</p>
|
</p>
|
||||||
|
<p><Link to="/journal">Zum Journal</Link></p>
|
||||||
<p className="muted">Keine Tracking-Kacheln, keine Aufgabenliste, keine Diagnose.</p>
|
<p className="muted">Keine Tracking-Kacheln, keine Aufgabenliste, keine Diagnose.</p>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
251
frontend/src/pages/JournalDayPage.jsx
Normal file
251
frontend/src/pages/JournalDayPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
372
frontend/src/pages/JournalEditorPage.jsx
Normal file
372
frontend/src/pages/JournalEditorPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
frontend/src/pages/JournalSourcePage.jsx
Normal file
31
frontend/src/pages/JournalSourcePage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
frontend/src/pages/JournalSpacePage.jsx
Normal file
77
frontend/src/pages/JournalSpacePage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
87
frontend/src/pages/JournalSpacesPage.jsx
Normal file
87
frontend/src/pages/JournalSpacesPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,353 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useId, useState } from 'react'
|
||||||
import { useAuth } from '../context/AuthContext.jsx'
|
import { useAuth } from '../context/AuthContext.jsx'
|
||||||
import { api } from '../api.js'
|
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() {
|
export default function SettingsPage() {
|
||||||
const { session, isAdmin } = useAuth()
|
const { session, isAdmin } = useAuth()
|
||||||
const [sub, setSub] = useState(null)
|
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))
|
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 (
|
return (
|
||||||
<section className="card">
|
<section className="card">
|
||||||
|
|
@ -30,6 +369,291 @@ export default function SettingsPage() {
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user