Compare commits

..

4 Commits

Author SHA1 Message Date
Lars
326a9a44c8 Record that the opening fix and transfer docs are committed on main.
The home clone still needs a Gitea push; personal SQLite stays out of git.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 08:26:38 +02:00
Lars
29e3d0ff74 Document the laptop-to-home transfer without moving personal data into git.
SQLite restore stays wave 1; Docker and Postgres stay wave 2. Session bootstrap for the switch is environment_handover.md plus the laptop and home work orders.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 08:25:20 +02:00
Lars
ec0f8bad55 Keep journal openings from treating finished days as open plans.
A 400-character entry prefix plus plan regex could reopen a completed yesterday as if it were still pending. Openings now take plans only from yesterday's user dialogue, never from saved entries, and skip space recency in the first call.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 08:25:00 +02:00
Lars
e42f751fdc MVP 1.0 2026-08-29 20:28:59 +02:00
61 changed files with 4834 additions and 329 deletions

View File

@ -20,6 +20,8 @@ Fachliche Konzeption, technische Rahmenarchitektur und der erste **vertikale MVP
Remote: [gitea.stommer.de/Lars/Kansho](https://gitea.stommer.de/Lars/Kansho.git)
Die Urlaubs-Laptop-Instanz lief ohne Docker auf SQLite. Heimtransfer, Backup und späterer Server-/Postgres-Pfad: `docs/architecture/technical/environment_handover.md`.
## Dokumentation zuerst laden
Nicht alle Kapitel gleichzeitig in den Kontext ziehen. Der Index beschreibt sinnvolle Bundles:
@ -87,6 +89,16 @@ Lokales Backup der Urlaubs-Testdaten (persönlich, unverschlüsselt, ohne `.env`
Restore bestätigt ausdrücklich, legt vorher ein Sicherheitsbackup an und überschreibt nie still. Backend währenddessen beenden.
## Transfer auf die Heim-Umgebung
Persönliche Daten liegen **nicht** in Git. Vor einem Rechnerwechsel:
1. `.\scripts\backup-local.ps1 create` und das Zip plus `backend/.env` getrennt, verschlüsselt kopieren.
2. Zu Hause klonen, `.\scripts\dev-setup.ps1`, Restore mit `-Confirm -Replace`.
3. Docker/Postgres sind Welle 2, nicht der erste Restore.
Details und Checklisten: `docs/architecture/technical/environment_handover.md`, `docs/work_orders/laptop_closeout.md`, `docs/work_orders/home_environment_setup.md`.
## Lokal weiterarbeiten
```powershell

View File

@ -1,5 +1,5 @@
{
"seed_revision": "2026-08-27-generation-guidelines-v1",
"seed_revision": "2026-08-29-voice-legacy-immutable-v1",
"purpose": "journal_generate",
"max_instruction_chars": 1000,
"max_label_chars": 80,
@ -79,31 +79,120 @@
{
"id": "journal-generate-voice-neutral",
"guideline_key": "neutral",
"label": "neutral",
"summary": "Neutraler Journalstil, Writing Profile nur als leise Tendenz.",
"instruction": "Schreibe in einem neutralen Journalstil. Das Writing Profile höchstens als leise Tendenz."
"label": "Ohne persönliches Profil",
"summary": "Natürlicher, neutraler Journalstil ohne Writing Profile und ohne Stilbeispiele.",
"instruction": "Schreibe in einem natürlichen, neutralen Journalstil. Der aktuelle Tagesdialog ist nur Inhaltsquelle, nicht Stilvorlage.",
"style_context": {
"include_core": false,
"include_facet": false,
"include_traits": false,
"include_style_examples": false
}
},
{
"id": "journal-generate-voice-light",
"guideline_key": "light",
"label": "dezent",
"summary": "Rhythmus und Wortwahl des Writing Profiles zurückhaltend.",
"instruction": "Nimm Rhythmus und Wortwahl des Writing Profiles zurückhaltend auf."
"label": "Persönliche Stimme dezent",
"summary": "Zurückhaltende Orientierung am Schreibkern, ohne Facet, Traits oder Beispiele.",
"instruction": "Orientiere dich zurückhaltend am mitgegebenen Schreibkern. Übernimm daraus keine Ereignisse, Personen oder Bewertungen.",
"style_context": {
"include_core": true,
"include_facet": false,
"include_traits": false,
"include_style_examples": false
}
},
{
"id": "journal-generate-voice-noticeable",
"guideline_key": "noticeable",
"label": "spürbar",
"summary": "Writing Profile spürbar, ohne in den Vordergrund zu treten.",
"instruction": "Wende das Writing Profile spürbar an, ohne es in den Vordergrund zu stellen."
"instruction": "Wende das Writing Profile spürbar an, ohne es in den Vordergrund zu stellen.",
"style_context": {
"include_core": true,
"include_facet": true,
"include_traits": true,
"include_style_examples": false
}
},
{
"id": "journal-generate-voice-clear",
"guideline_key": "clear",
"label": "deutlich",
"summary": "Rhythmus, Wortwahl und Reflexionsdichte des Writing Profiles deutlich.",
"label": "Persönliche Stimme deutlich",
"summary": "Deutliche Orientierung an Tonlage, Rhythmus und Wortwahl des Profils.",
"instruction": "Wende Tonlage, Rhythmus und Wortwahl des mitgegebenen Writing Profiles deutlich an, ohne den Text künstlich zu literarisieren. Übernimm daraus keine Ereignisse, Personen oder Bewertungen.",
"is_default": true,
"style_context": {
"include_core": true,
"include_facet": true,
"include_traits": true,
"include_style_examples": false
}
},
{
"id": "journal-generate-voice-with-examples",
"guideline_key": "with_examples",
"label": "Persönliche Stimme mit Beispielen",
"summary": "Deutliche Profilanwendung; historische Texte nur als sprachliche Referenz.",
"instruction": "Wende Tonlage, Rhythmus und Wortwahl des mitgegebenen Writing Profiles deutlich an. STYLE_EXAMPLES sind ausschließlich sprachliche Referenz; ihre Ereignisse, Personen, Orte und Bewertungen gehören nicht zum heutigen Tag.",
"style_context": {
"include_core": true,
"include_facet": true,
"include_traits": true,
"include_style_examples": true
}
},
{
"id": "journal-generate-voice-legacy-neutral",
"guideline_key": "legacy_neutral",
"label": "Legacy vor Stilkontextsteuerung · neutral",
"summary": "Rekonstruierte frühere Semantik: Neutraler Journalstil, Writing Profile nur als leise Tendenz.",
"instruction": "Schreibe in einem neutralen Journalstil. Das Writing Profile höchstens als leise Tendenz.",
"style_context": {
"include_core": true,
"include_facet": true,
"include_traits": true,
"include_style_examples": true
}
},
{
"id": "journal-generate-voice-legacy-light",
"guideline_key": "legacy_light",
"label": "Legacy vor Stilkontextsteuerung · dezent",
"summary": "Rekonstruierte frühere Semantik: Rhythmus und Wortwahl des Writing Profiles zurückhaltend.",
"instruction": "Nimm Rhythmus und Wortwahl des Writing Profiles zurückhaltend auf.",
"style_context": {
"include_core": true,
"include_facet": true,
"include_traits": true,
"include_style_examples": true
}
},
{
"id": "journal-generate-voice-legacy-noticeable",
"guideline_key": "legacy_noticeable",
"label": "Legacy vor Stilkontextsteuerung · spürbar",
"summary": "Rekonstruierte frühere Semantik: Writing Profile spürbar, ohne in den Vordergrund zu treten.",
"instruction": "Wende das Writing Profile spürbar an, ohne es in den Vordergrund zu stellen.",
"style_context": {
"include_core": true,
"include_facet": true,
"include_traits": true,
"include_style_examples": true
}
},
{
"id": "journal-generate-voice-legacy-clear",
"guideline_key": "legacy_clear",
"label": "Legacy vor Stilkontextsteuerung · deutlich",
"summary": "Rekonstruierte frühere Semantik: Rhythmus, Wortwahl und Reflexionsdichte des Writing Profiles deutlich.",
"instruction": "Wende Rhythmus, Wortwahl und Reflexionsdichte des Writing Profiles deutlich an, ohne den Text künstlich zu literarisieren.",
"is_default": true
"style_context": {
"include_core": true,
"include_facet": true,
"include_traits": true,
"include_style_examples": true
}
}
]
},

View File

@ -17,8 +17,8 @@
"category": "mvp",
"prompt_type": "base",
"required_feature": "ai_calls",
"template": "Du redigierst einen persönlichen Tagebucheintrag in der Ich-Form.\n\nAUFGABE\nFormuliere aus CURRENT_DAY_SOURCES einen lesenswerten, eigenständigen Journaltext. Die Quellen bestimmen, was geschehen ist. WRITING_PROFILE bestimmt, wie es erzählt wird. Quellsyntax, Rechtschreibfehler und Dialogstruktur sind keine Ausgabevorlage.\n\nBEARBEITUNG\n{{transformation_instructions}}\n{{detail_instructions}}\n{{voice_instructions}}\n{{narrative_instructions}}\n\nINHALTSTREUE\nVerändere keine Ereignisse, Beteiligten, Orte, Zeiten oder zeitlichen Abläufe. Erhalte Verneinungen, Unsicherheiten, Korrekturen, ausdrücklich genannte Gefühle und Bewertungen sowie den Unterschied zwischen Plan und Vollzug. Ergänze keine neuen Tatsachen, Ursachen, Motive, Gefühle oder Hintergrundinformationen. Übergänge dürfen verbinden, aber nichts erklären, was die Quellen nicht erklären. Bei unklaren Satzfragmenten nicht raten, sondern neutral umformulieren oder nur den unverständlichen Teil weglassen.\n\nSTIL\nKorrigiere Rechtschreibung, Grammatik und Zeichensetzung. Verbessere Satzbau, Rhythmus, Übergänge und Absätze. Fasse echte Wiederholungen zusammen. Formuliere schwache oder fehlerhafte Stellen eigenständig neu. Gute persönliche Formulierungen dürfen erhalten bleiben. STYLE_EXAMPLES dienen ausschließlich als Stilreferenz; ihre Inhalte gehören nicht zum heutigen Tag.\nDie Quellen können aus Fließtext, Stichpunkten und Satzfragmenten bestehen. Behandle jede Passage entsprechend ihrer Form: Redigiere vorhandenen Fließtext, verwandle Stichpunkte und Fragmente in vollständige Prosa und verbinde beides zu einem einheitlichen Eintrag. Zwinge nicht den gesamten Tag in einen einzigen Quellenmodus.\n\nDATENSCHUTZ\nÜbernimm vorhandene Platzhalter wie [[PERSON:01]] unverändert. Erzeuge keine neuen Platzhalter und schreibe keine Klarnamen an ihre Stelle.\n\nKONTEXT\n\nWRITING_PROFILE\n{{writing_profile}}\n\nSTYLE_EXAMPLES\n{{style_examples}}\n\nCURRENT_DAY_SOURCES\n{{reconstruction}}\n\nEXISTING_TEXT\n{{existing_text}}\n\nAUSGABE\nErste Zeile: eine kurze passende Überschrift ohne neue Tatsachen. Danach zusammenhängende, natürlich gegliederte Absätze. Keine Aufzählung und keine Meta-Erklärung.\n",
"seed_revision": "2026-08-27-journal-mixed-sources-v1"
"template": "Du redigierst einen persönlichen Tagebucheintrag in der Ich-Form.\n\nAUFGABE\nFormuliere aus CURRENT_DAY_SOURCES einen lesenswerten, eigenständigen Journaltext. Die Quellen bestimmen, was geschehen ist. Ein mitgegebenes WRITING_PROFILE bestimmt, wie es erzählt wird; fehlt es, gilt nur die Stimmanweisung. Quellsyntax, Rechtschreibfehler und Dialogstruktur sind keine Ausgabevorlage.\n\nBEARBEITUNG\n{{transformation_instructions}}\n{{detail_instructions}}\n{{voice_instructions}}\n{{narrative_instructions}}\n\nINHALTSTREUE\nVerändere keine Ereignisse, Beteiligten, Orte, Zeiten oder zeitlichen Abläufe. Erhalte Verneinungen, Unsicherheiten, Korrekturen, ausdrücklich genannte Gefühle und Bewertungen sowie den Unterschied zwischen Plan und Vollzug. Ergänze keine neuen Tatsachen, Ursachen, Motive, Gefühle oder Hintergrundinformationen. Übergänge dürfen verbinden, aber nichts erklären, was die Quellen nicht erklären. Bei unklaren Satzfragmenten nicht raten, sondern neutral umformulieren oder nur den unverständlichen Teil weglassen.\n\nSTIL\nKorrigiere Rechtschreibung, Grammatik und Zeichensetzung. Verbessere Satzbau, Rhythmus, Übergänge und Absätze. Fasse echte Wiederholungen zusammen. Formuliere schwache oder fehlerhafte Stellen eigenständig neu. Gute persönliche Formulierungen dürfen erhalten bleiben. Der aktuelle Tagesdialog ist keine Stilvorlage.\nDie Quellen können aus Fließtext, Stichpunkten und Satzfragmenten bestehen. Behandle jede Passage entsprechend ihrer Form: Redigiere vorhandenen Fließtext, verwandle Stichpunkte und Fragmente in vollständige Prosa und verbinde beides zu einem einheitlichen Eintrag. Zwinge nicht den gesamten Tag in einen einzigen Quellenmodus.\n\nDATENSCHUTZ\nÜbernimm vorhandene Platzhalter wie [[PERSON:01]] unverändert. Erzeuge keine neuen Platzhalter und schreibe keine Klarnamen an ihre Stelle.\n\nKONTEXT\n{{writing_profile}}{{style_examples}}\nCURRENT_DAY_SOURCES\n{{reconstruction}}\n\nEXISTING_TEXT\n{{existing_text}}\n\nAUSGABE\nErste Zeile: eine kurze passende Überschrift ohne neue Tatsachen. Danach zusammenhängende, natürlich gegliederte Absätze. Keine Aufzählung und keine Meta-Erklärung.\n",
"seed_revision": "2026-08-29-voice-legacy-immutable-v1"
},
{
"id": "mvp-journal-reconstruct",

View File

@ -70,11 +70,13 @@ def build_internal_context(
space_id: str | None = None,
journal_day_id: str | None = None,
purpose: str = "dialogue_turn",
include_space_recency: bool = True,
include_existing: bool = False,
conversation_ids: list[str] | None = None,
existing_text: str = "",
reconstruction: str = "",
day_spec: dict | None = None,
writing_profile: str | None = None,
style_examples: str = "",
transformation_instructions: str = "",
detail_instructions: str = "",
@ -117,7 +119,7 @@ def build_internal_context(
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:
if purpose == "dialogue_turn" and space_id and include_space_recency:
prior = retrieve(
profile_id,
{
@ -151,9 +153,12 @@ def build_internal_context(
items.append({"type": "space_recent_sources", "sources": recent_sources})
if purpose == "journal_generate":
from writing_profile_store import compile_task_brief
if writing_profile is None:
from writing_profile_store import compile_task_brief
brief = compile_task_brief(profile_id, "journal_generate")
brief = compile_task_brief(profile_id, "journal_generate")
else:
brief = writing_profile
items.append({"type": "writing_profile", "compiled_brief": brief})
items.append({"type": "style_examples", "body": style_examples or ""})
items.append({"type": "transformation_instructions", "text": transformation_instructions or ""})

View File

@ -66,6 +66,9 @@ _JOURNAL_DAY_COLUMNS = {
_JOURNAL_DRAFT_COLUMNS = {
"generation_snapshot": "TEXT NOT NULL DEFAULT '{}'",
}
_GUIDELINE_COLUMNS = {
"style_context_json": "TEXT NOT NULL DEFAULT '{}'",
}
_DEBUG_RUN_COLUMNS = {
"space_id": "TEXT",
"journal_day_id": "TEXT",
@ -487,6 +490,7 @@ def init_db() -> None:
_mark(conn, "014_identity_registry")
from journal_generation_policy import backfill_missing_settings, seed_generation_instructions
_ensure_columns(conn, "generation_guidelines", _GUIDELINE_COLUMNS)
seed_generation_instructions(conn)
backfill_missing_settings(conn)
_mark(conn, "015_journal_generation_settings")
@ -495,6 +499,10 @@ def init_db() -> None:
_mark(conn, "018_debug_runs")
_ensure_columns(conn, "debug_runs", _DEBUG_RUN_COLUMNS)
_mark(conn, "019_debug_run_placement")
seed_generation_instructions(conn)
_mark(conn, "020_voice_style_context")
seed_generation_instructions(conn)
_mark(conn, "021_voice_legacy_immutable")
from writing_profile_store import bootstrap_from_existing
bootstrap_from_existing()

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from typing import Any
import uuid
@ -36,6 +37,8 @@ LAYER_FOR_PURPOSE = {
"journal_reconstruct": "journalrekonstruktion",
"profile_review": "profilreview",
}
SANITIZE_MAX_DEPTH = 80
LOGGER = logging.getLogger("kansho.debug")
def _now() -> str:
@ -78,18 +81,26 @@ def should_persist(profile_id: str | None) -> bool:
return persist_enabled() and _is_admin(profile_id)
def sanitize(value: Any) -> Any:
if isinstance(value, dict):
cleaned = {}
for key, item in value.items():
if key in FORBIDDEN_KEYS:
continue
cleaned[key] = sanitize(item)
return cleaned
if isinstance(value, list):
return [sanitize(item) for item in value]
if isinstance(value, tuple):
return [sanitize(item) for item in value]
def sanitize(value: Any, *, _stack: set[int] | None = None, _depth: int = 0) -> Any:
if _depth > SANITIZE_MAX_DEPTH:
return None
if isinstance(value, (dict, list, tuple)):
stack = _stack if _stack is not None else set()
ident = id(value)
if ident in stack:
return None
stack.add(ident)
try:
if isinstance(value, dict):
cleaned = {}
for key, item in value.items():
if key in FORBIDDEN_KEYS:
continue
cleaned[key] = sanitize(item, _stack=stack, _depth=_depth + 1)
return cleaned
return [sanitize(item, _stack=stack, _depth=_depth + 1) for item in value]
finally:
stack.discard(ident)
return value
@ -251,24 +262,59 @@ def persist_engine_error(
journal_day_id: str | None = None,
extra: dict | None = None,
) -> str | None:
diag = dict(getattr(exc, "diagnostics", None) or {})
merged = dict(extra or {})
merged.setdefault("code", getattr(exc, "code", None))
merged.setdefault("message", getattr(exc, "message", None) or str(exc))
if diag.get("log") is not None:
merged.setdefault("run_log", diag.get("log"))
return persist_step(
profile_id,
purpose=purpose,
status="error",
layer=layer,
subject_type=subject_type,
subject_id=subject_id,
conversation_id=conversation_id,
journal_day_id=journal_day_id,
trace=diag.get("trace"),
extra=merged,
)
try:
diag = dict(getattr(exc, "diagnostics", None) or {})
merged = dict(extra or {})
merged.setdefault("code", getattr(exc, "code", None))
merged.setdefault("message", getattr(exc, "message", None) or str(exc))
log = diag.get("log")
if log is not None:
merged.setdefault("run_log", list(log) if isinstance(log, list) else log)
trace = diag.get("trace") if isinstance(diag.get("trace"), dict) else {}
try:
from privacy_gateway import compact_diagnostics, public_cost_report
compact = compact_diagnostics({**diag, **trace, **merged})
for key, value in compact.items():
merged.setdefault(key, value)
merged.setdefault("cost_report", public_cost_report({**diag, **merged}))
except Exception:
LOGGER.exception("Kompakte Fehlerdiagnose konnte nicht erzeugt werden.")
for key in (
"generation_selection",
"style_application",
"abort_reason",
"detect_attempts",
"detect_passes",
"detect_partial_discarded",
"contract_violation",
"invalid_entity_type",
"generate_called",
):
if merged.get(key) is None:
if diag.get(key) is not None:
merged[key] = diag.get(key)
elif trace.get(key) is not None:
merged[key] = trace.get(key)
merged.setdefault("generate_called", False)
return persist_step(
profile_id,
purpose=purpose,
status="error",
layer=layer,
subject_type=subject_type,
subject_id=subject_id,
conversation_id=conversation_id,
journal_day_id=journal_day_id,
trace=trace or None,
extra=merged,
)
except Exception:
LOGGER.exception(
"Diagnosepersistenz fehlgeschlagen; ursprünglicher Fehler bleibt %s",
getattr(exc, "code", type(exc).__name__),
)
return None
def _parse_payload(raw: str | None) -> dict:

View File

@ -5,6 +5,7 @@ spans and types only, never tokens, never a durable registry write.
"""
from __future__ import annotations
import contextvars
import json
import re
import time
@ -41,6 +42,23 @@ ERROR_DETECT_INCOMPLETE = "detect_incomplete"
ERROR_DETECT_INVALID = "detect_invalid_output"
ERROR_DETECT_TRUNCATED = "detect_truncated"
ERROR_DETECT_CHUNK = "detect_chunk_failed"
DETECT_MAX_PASSES = 2
DETECT_CONTRACT_RETRY_CODES = frozenset(
{
ERROR_DETECT_INVALID,
ERROR_DETECT_TRUNCATED,
ERROR_DETECT_INCOMPLETE,
}
)
SCHEMA_RETRY_HINT = (
"Der vorherige Durchgang hat das verbindliche Schema nicht erfüllt. "
"Antworte ausschließlich mit dem geforderten JSON-Objekt. "
"Nur die Felder start, end, text und entity_type sind zulässig. "
"Nur entity_type-Werte PERSON, PLACE, ORG, PROJECT. "
"Offsets sind 0-basiert und end ausschließlich, bezogen auf den Text nach «Text:». "
"text muss ein exakter Substring dieses Texts sein. "
"Keine zusätzlichen Felder, keine Tokens, keine Platzhalter, keine Erklärungen."
)
DETECT_DIALOGUE_FALLBACK_CODES = frozenset(
{
ERROR_DETECT_INVALID,
@ -117,8 +135,14 @@ class DetectionStats:
completion_tokens: int = 0
total_tokens: int = 0
cost: float = 0.0
cost_known: bool = False
detect_ms: int = 0
abort_reason: str | None = None
detect_passes: int = 0
detect_partial_discarded: bool = False
detect_attempts: list[dict[str, Any]] = field(default_factory=list)
contract_violation: str | None = None
invalid_entity_type: str | None = None
def public(self) -> dict[str, Any]:
payload = {
@ -138,11 +162,23 @@ class DetectionStats:
"detect_prompt_tokens": self.prompt_tokens,
"detect_completion_tokens": self.completion_tokens,
"detect_total_tokens": self.total_tokens,
"detect_cost": self.cost,
"detect_ms": self.detect_ms,
"detect_passes": self.detect_passes,
"detect_partial_discarded": self.detect_partial_discarded,
"detect_attempts": [dict(item) for item in self.detect_attempts],
"generate_called": False,
}
if self.cost_known:
payload["detect_cost"] = self.cost
payload["detect_cost_unknown"] = False
else:
payload["detect_cost_unknown"] = True
if self.abort_reason:
payload["abort_reason"] = self.abort_reason
if self.contract_violation:
payload["contract_violation"] = self.contract_violation
if self.invalid_entity_type:
payload["invalid_entity_type"] = self.invalid_entity_type
return payload
@ -153,32 +189,51 @@ class DetectionOutcome:
local_identities: list[dict]
_injected_spans: list[dict] | None = None
_injected_fail: DetectError | None = None
_injected_truncated: bool = False
_injected_spans: contextvars.ContextVar[list[dict] | None] = contextvars.ContextVar(
"kansho_detect_spans",
default=None,
)
_injected_fail: contextvars.ContextVar[DetectError | None] = contextvars.ContextVar(
"kansho_detect_fail",
default=None,
)
_injected_truncated: contextvars.ContextVar[bool] = contextvars.ContextVar(
"kansho_detect_truncated",
default=False,
)
_injected_script: contextvars.ContextVar[Any] = contextvars.ContextVar(
"kansho_detect_script",
default=None,
)
def install_test_spans(spans: list[dict] | None) -> None:
"""Test-only span injection. Production never calls this."""
global _injected_spans
_injected_spans = None if spans is None else [dict(item) for item in spans]
_injected_spans.set(None if spans is None else [dict(item) for item in spans])
def install_test_detect_failure(error: DetectError | None) -> None:
global _injected_fail
_injected_fail = error
_injected_fail.set(error)
def install_test_truncated(flag: bool = True) -> None:
global _injected_truncated
_injected_truncated = bool(flag)
_injected_truncated.set(bool(flag))
def install_test_detect_script(handler=None) -> None:
"""Test-only per-attempt chunk script. Production never calls this.
handler(attempt, chunk_index, chunk_text, offset, schema_retry=...) may return
entity lists, raw JSON, ChatResult, or DetectError. Attempt is 1-based.
"""
_injected_script.set(handler)
def reset_detect_test_hooks() -> None:
global _injected_spans, _injected_fail, _injected_truncated
_injected_spans = None
_injected_fail = None
_injected_truncated = False
_injected_spans.set(None)
_injected_fail.set(None)
_injected_truncated.set(False)
_injected_script.set(None)
def uses_llm_detect(config) -> bool:
@ -295,25 +350,41 @@ def _contract_fake_spans(text: str) -> list[dict]:
return found
def _contract_error(
message: str,
*,
violation: str,
code: str = ERROR_DETECT_INVALID,
invalid_entity_type: str | None = None,
status_code: int = 503,
) -> DetectError:
diagnostics: dict[str, Any] = {"contract_violation": violation}
if invalid_entity_type:
delivered = re.sub(r"[^A-Z0-9_:-]", "", str(invalid_entity_type).strip().upper())[:40]
if delivered:
diagnostics["invalid_entity_type"] = delivered
return DetectError(code, message, status_code, diagnostics)
def _parse_detect_json(raw: str) -> dict:
text = (raw or "").strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text, flags=re.IGNORECASE | re.DOTALL).strip()
match = JSON_BLOCK.search(text)
if not match:
raise DetectError(ERROR_DETECT_INVALID, "Detect-Antwort war kein gültiges JSON.")
raise _contract_error("Detect-Antwort war kein gültiges JSON.", violation="invalid_json")
try:
data = json.loads(match.group(0))
except json.JSONDecodeError as exc:
raise DetectError(ERROR_DETECT_INVALID, "Detect-Antwort war kein gültiges JSON.") from exc
raise _contract_error("Detect-Antwort war kein gültiges JSON.", violation="invalid_json") from exc
if not isinstance(data, dict):
raise DetectError(ERROR_DETECT_INVALID, "Detect-Antwort muss ein Objekt mit entities sein.")
raise _contract_error("Detect-Antwort muss ein Objekt mit entities sein.", violation="invalid_json")
extra = set(data.keys()) - ALLOWED_ROOT_FIELDS
if extra:
raise DetectError(ERROR_DETECT_INVALID, "Detect-Antwort enthält unerwartete Felder.")
raise _contract_error("Detect-Antwort enthält unerwartete Felder.", violation="extra_fields")
items = data.get("entities")
if not isinstance(items, list):
raise DetectError(ERROR_DETECT_INVALID, "Detect-entities muss eine Liste sein.")
raise _contract_error("Detect-entities muss eine Liste sein.", violation="invalid_json")
return data
@ -364,13 +435,13 @@ def _ground_span(chunk_text: str, start: int, end: int, text: str) -> tuple[int,
def validate_detected_entity(item: Any, chunk_text: str, chunk_index: int) -> DetectedSpan | None:
if not isinstance(item, dict):
raise DetectError(ERROR_DETECT_INVALID, "Detect-Entity muss ein Objekt sein.")
raise _contract_error("Detect-Entity muss ein Objekt sein.", violation="invalid_entity")
extra = set(item.keys()) - ALLOWED_ENTITY_FIELDS
if extra:
raise DetectError(ERROR_DETECT_INVALID, "Detect-Entity enthält unerwartete Felder.")
raise _contract_error("Detect-Entity enthält unerwartete Felder.", violation="extra_fields")
missing = ALLOWED_ENTITY_FIELDS - set(item.keys())
if missing:
raise DetectError(ERROR_DETECT_INVALID, "Detect-Entity ist unvollständig.")
raise _contract_error("Detect-Entity ist unvollständig.", violation="missing_fields")
try:
start = int(item["start"])
except (TypeError, ValueError):
@ -381,17 +452,26 @@ def validate_detected_entity(item: Any, chunk_text: str, chunk_index: int) -> De
end = 0
text = item.get("text")
if not isinstance(text, str):
raise DetectError(ERROR_DETECT_INVALID, "Detect-text fehlt.")
raise _contract_error("Detect-text fehlt.", violation="missing_fields")
if not text.strip():
return None
raise _contract_error("Detect-text ist leer.", violation="invalid_text")
if "token" in item or "placeholder" in item:
raise DetectError(ERROR_DETECT_INVALID, "Detect darf keine Tokens festlegen.")
kind = normalize_entity_type(item.get("entity_type"), default="")
raise _contract_error("Detect darf keine Tokens festlegen.", violation="extra_fields")
raw_type = item.get("entity_type")
kind = normalize_entity_type(raw_type if isinstance(raw_type, str) else "", default="")
if not kind:
raise DetectError(ERROR_DETECT_INVALID, "Detect-entity_type ist nicht erlaubt.")
delivered = raw_type if isinstance(raw_type, str) else ""
raise _contract_error(
"Detect-entity_type ist nicht erlaubt.",
violation="unknown_entity_type",
invalid_entity_type=delivered,
)
grounded = _ground_span(chunk_text, start, end, text)
if not grounded:
return None
raise _contract_error(
"Detect-Offsets sind nicht im Chunk verankert.",
violation="unusable_offsets",
)
left, right, local_text = grounded
return DetectedSpan(start=left, end=right, text=local_text, entity_type=kind, chunk_index=chunk_index)
@ -419,20 +499,30 @@ def resolve_overlaps(spans: list[DetectedSpan]) -> list[DetectedSpan]:
def _add_usage(stats: DetectionStats, usage: dict | None) -> None:
data = usage or {}
stats.prompt_tokens += int(data.get("prompt_tokens") or 0)
stats.completion_tokens += int(data.get("completion_tokens") or 0)
stats.total_tokens += int(data.get("total_tokens") or 0)
try:
stats.cost += float(data.get("cost") or 0)
except (TypeError, ValueError):
pass
if data.get("prompt_tokens") is not None:
stats.prompt_tokens += int(data.get("prompt_tokens") or 0)
if data.get("completion_tokens") is not None:
stats.completion_tokens += int(data.get("completion_tokens") or 0)
if data.get("total_tokens") is not None:
stats.total_tokens += int(data.get("total_tokens") or 0)
extra = data.get("cost")
if extra is None:
extra = data.get("total_cost")
if extra is not None:
try:
stats.cost += float(extra)
stats.cost_known = True
except (TypeError, ValueError):
pass
def _llm_chunk(config, excerpt: str) -> ChatResult:
def _llm_chunk(config, excerpt: str, *, schema_retry: bool = False) -> ChatResult:
prompt = resolve_template(
_detect_prompt()["template"],
{"source_text": excerpt, "known_labels": ""},
)
if schema_retry:
prompt = f"{prompt.rstrip()}\n\n{SCHEMA_RETRY_HINT}"
return complete_chat(
config,
[{"role": "user", "content": prompt}],
@ -447,6 +537,7 @@ def _entities_from_result(result: ChatResult, chunk_text: str, chunk_index: int)
raise DetectError(
ERROR_DETECT_TRUNCATED,
"Detect-Ausgabe wurde abgeschnitten. Generate wird nicht freigegeben.",
diagnostics={"contract_violation": "truncated"},
)
data = _parse_detect_json(result.content or "")
spans: list[DetectedSpan] = []
@ -571,18 +662,192 @@ def _merge_confirmed_safety_net(text: str, mappings: list[dict], profile_id: str
return mappings + extra, hits
def _merge_pass_stats(overall: DetectionStats, pass_stats: DetectionStats) -> None:
overall.detect_calls += pass_stats.detect_calls
overall.prompt_tokens += pass_stats.prompt_tokens
overall.completion_tokens += pass_stats.completion_tokens
overall.total_tokens += pass_stats.total_tokens
overall.chunk_count = pass_stats.chunk_count
overall.chunks_ok = pass_stats.chunks_ok
overall.detect_note = pass_stats.detect_note or overall.detect_note
overall.detect_model = pass_stats.detect_model or overall.detect_model
if pass_stats.cost_known:
overall.cost_known = True
overall.cost += pass_stats.cost
def _pass_snapshot(
pass_stats: DetectionStats,
*,
attempt: int,
coverage: bool,
abort: str | None = None,
) -> dict[str, Any]:
row: dict[str, Any] = {
"attempt": attempt,
"schema_retry": attempt > 1,
"chunk_count": pass_stats.chunk_count,
"chunks_ok": pass_stats.chunks_ok,
"detect_calls": pass_stats.detect_calls,
"full_detection_coverage": coverage,
"detect_prompt_tokens": pass_stats.prompt_tokens,
"detect_completion_tokens": pass_stats.completion_tokens,
"detect_total_tokens": pass_stats.total_tokens,
"detect_ms": pass_stats.detect_ms,
}
if pass_stats.cost_known:
row["detect_cost"] = pass_stats.cost
row["detect_cost_unknown"] = False
else:
row["detect_cost_unknown"] = True
if abort:
row["abort_reason"] = abort
if pass_stats.contract_violation:
row["contract_violation"] = pass_stats.contract_violation
if pass_stats.invalid_entity_type:
row["invalid_entity_type"] = pass_stats.invalid_entity_type
return row
def _script_result(raw: Any, *, model: str | None) -> ChatResult:
if isinstance(raw, DetectError):
raise raw
if isinstance(raw, ChatResult):
return raw
if isinstance(raw, str):
return ChatResult(content=raw, model=model, usage={}, finish_reason="stop")
if isinstance(raw, dict) and "entities" in raw:
return ChatResult(content=json.dumps(raw), model=model, usage={}, finish_reason="stop")
if isinstance(raw, list):
return ChatResult(
content=json.dumps({"entities": raw}),
model=model,
usage={},
finish_reason="stop",
)
raise _contract_error("Detect-Testskript lieferte keine verwertbare Antwort.", violation="invalid_json")
def _chunk_chat_result(
config,
chunk_text: str,
*,
offset: int,
index: int,
attempt: int,
schema_retry: bool,
stats: DetectionStats,
) -> ChatResult:
if _injected_truncated.get():
raise DetectError(
ERROR_DETECT_TRUNCATED,
"Detect-Ausgabe wurde abgeschnitten. Generate wird nicht freigegeben.",
diagnostics={"contract_violation": "truncated"},
)
script = _injected_script.get()
if script is not None:
stats.detect_note = "injected"
try:
raw = script(attempt, index, chunk_text, offset, schema_retry=schema_retry)
except TypeError:
raw = script(attempt, index, chunk_text, offset)
return _script_result(raw, model=config.model)
spans = _injected_spans.get()
if spans is not None:
raw_items = [
item
for item in spans
if int(item.get("start") or 0) >= offset
and int(item.get("end") or 0) <= offset + len(chunk_text)
]
local_items = []
for item in raw_items:
local = dict(item)
local["start"] = int(item["start"]) - offset
local["end"] = int(item["end"]) - offset
local_items.append(local)
stats.detect_note = "injected"
return ChatResult(
content=json.dumps({"entities": local_items}),
model=config.model,
usage={},
finish_reason="stop",
)
if config.mode == "fake":
stats.detect_note = "fake"
return ChatResult(
content=json.dumps({"entities": _contract_fake_spans(chunk_text)}),
model=config.model,
usage={},
finish_reason="stop",
)
if uses_llm_detect(config):
try:
result = _llm_chunk(config, chunk_text, schema_retry=schema_retry)
except PlaceholderError as exc:
raise DetectError(exc.code, exc.message) from exc
except ProviderError as exc:
raise DetectError(
ERROR_DETECT_CHUNK,
"Ein Detect-Chunk ist fehlgeschlagen. Generate wird nicht freigegeben.",
exc.status_code,
) from exc
stats.detect_note = "local_llm" if config.local else "remote_llm"
stats.detect_model = result.model or stats.detect_model
return result
raise DetectError(
ERROR_DETECT_UNAVAILABLE,
"Semantische Detection ist nicht verfügbar. Generate wird nicht freigegeben.",
)
def _run_detection_pass(
config,
chunks: list[tuple[int, str]],
*,
attempt: int,
schema_retry: bool,
stats: DetectionStats,
) -> list[DetectedSpan]:
collected: list[DetectedSpan] = []
for index, (offset, chunk_text) in enumerate(chunks):
result = _chunk_chat_result(
config,
chunk_text,
offset=offset,
index=index,
attempt=attempt,
schema_retry=schema_retry,
stats=stats,
)
stats.detect_calls += 1
_add_usage(stats, result.usage)
spans = _entities_from_result(result, chunk_text, index)
collected.extend(_to_global(span, offset) for span in spans)
stats.chunks_ok += 1
if stats.chunks_ok != stats.chunk_count:
raise DetectError(
ERROR_DETECT_INCOMPLETE,
"Detection hat nicht alle Chunks geprüft. Generate wird nicht freigegeben.",
diagnostics={"contract_violation": "incomplete"},
)
return collected
def detect_personal_egress(profile_id: str | None, source_text: str) -> DetectionOutcome:
"""Full semantic detection of the personal generate-egress. Fail closed if incomplete."""
started = time.perf_counter()
stats = DetectionStats(source_chars=len(source_text or ""))
if _injected_fail is not None:
stats.abort_reason = _injected_fail.code
injected_fail = _injected_fail.get()
if injected_fail is not None:
stats.abort_reason = injected_fail.code
stats.detect_ms = int((time.perf_counter() - started) * 1000)
stats.detect_passes = 0
raise DetectError(
_injected_fail.code,
_injected_fail.message,
_injected_fail.status_code,
{**(_injected_fail.diagnostics or {}), **stats.public()},
injected_fail.code,
injected_fail.message,
injected_fail.status_code,
{**(injected_fail.diagnostics or {}), **stats.public()},
)
config = detect_provider()
if not config:
@ -606,83 +871,70 @@ def detect_personal_egress(profile_id: str | None, source_text: str) -> Detectio
chunks = split_detect_chunks(source_text or "")
stats.chunk_count = len(chunks)
collected: list[DetectedSpan] = []
try:
for index, (offset, chunk_text) in enumerate(chunks):
if _injected_truncated:
raise DetectError(
ERROR_DETECT_TRUNCATED,
"Detect-Ausgabe wurde abgeschnitten. Generate wird nicht freigegeben.",
)
if _injected_spans is not None:
raw_items = [
item
for item in _injected_spans
if int(item.get("start") or 0) >= offset
and int(item.get("end") or 0) <= offset + len(chunk_text)
]
local_items = []
for item in raw_items:
local = dict(item)
local["start"] = int(item["start"]) - offset
local["end"] = int(item["end"]) - offset
local_items.append(local)
result = ChatResult(
content=json.dumps({"entities": local_items}),
model=config.model,
usage={},
finish_reason="stop",
)
stats.detect_note = "injected"
elif config.mode == "fake":
fake_items = _contract_fake_spans(chunk_text)
result = ChatResult(
content=json.dumps({"entities": fake_items}),
model=config.model,
usage={},
finish_reason="stop",
)
stats.detect_note = "fake"
elif uses_llm_detect(config):
try:
result = _llm_chunk(config, chunk_text)
except PlaceholderError as exc:
raise DetectError(exc.code, exc.message) from exc
except ProviderError as exc:
raise DetectError(
ERROR_DETECT_CHUNK,
"Ein Detect-Chunk ist fehlgeschlagen. Generate wird nicht freigegeben.",
exc.status_code,
) from exc
stats.detect_note = "local_llm" if config.local else "remote_llm"
stats.detect_model = result.model or stats.detect_model
else:
raise DetectError(
ERROR_DETECT_UNAVAILABLE,
"Semantische Detection ist nicht verfügbar. Generate wird nicht freigegeben.",
)
stats.detect_calls += 1
_add_usage(stats, result.usage)
spans = _entities_from_result(result, chunk_text, index)
collected.extend(_to_global(span, offset) for span in spans)
stats.chunks_ok += 1
except DetectError as exc:
stats.abort_reason = exc.code
stats.full_detection_coverage = False
stats.detect_ms = int((time.perf_counter() - started) * 1000)
raise DetectError(
exc.code,
exc.message,
exc.status_code,
{**(exc.diagnostics or {}), **stats.public()},
) from exc
if stats.chunks_ok != stats.chunk_count:
stats.abort_reason = ERROR_DETECT_INCOMPLETE
stats.detect_ms = int((time.perf_counter() - started) * 1000)
raise DetectError(
ERROR_DETECT_INCOMPLETE,
"Detection hat nicht alle Chunks geprüft. Generate wird nicht freigegeben.",
diagnostics=stats.public(),
last_error: DetectError | None = None
for attempt in range(1, DETECT_MAX_PASSES + 1):
pass_stats = DetectionStats(
source_chars=stats.source_chars,
detect_provider=stats.detect_provider,
detect_model=stats.detect_model,
chunk_count=len(chunks),
)
pass_started = time.perf_counter()
try:
collected = _run_detection_pass(
config,
chunks,
attempt=attempt,
schema_retry=attempt > 1,
stats=pass_stats,
)
pass_stats.full_detection_coverage = True
pass_stats.detect_ms = int((time.perf_counter() - pass_started) * 1000)
_merge_pass_stats(stats, pass_stats)
stats.detect_attempts.append(
_pass_snapshot(pass_stats, attempt=attempt, coverage=True)
)
last_error = None
break
except DetectError as exc:
inner = dict(exc.diagnostics or {})
pass_stats.abort_reason = exc.code
pass_stats.contract_violation = inner.get("contract_violation")
pass_stats.invalid_entity_type = inner.get("invalid_entity_type")
pass_stats.full_detection_coverage = False
pass_stats.detect_ms = int((time.perf_counter() - pass_started) * 1000)
_merge_pass_stats(stats, pass_stats)
stats.detect_attempts.append(
_pass_snapshot(pass_stats, attempt=attempt, coverage=False, abort=exc.code)
)
collected = []
last_error = DetectError(exc.code, exc.message, exc.status_code, inner)
retryable = exc.code in DETECT_CONTRACT_RETRY_CODES
if attempt < DETECT_MAX_PASSES and retryable:
stats.detect_partial_discarded = True
continue
stats.abort_reason = exc.code
stats.contract_violation = pass_stats.contract_violation
stats.invalid_entity_type = pass_stats.invalid_entity_type
stats.full_detection_coverage = False
stats.detect_passes = attempt
stats.detect_ms = int((time.perf_counter() - started) * 1000)
raise DetectError(
exc.code,
exc.message,
exc.status_code,
{**inner, **stats.public()},
) from exc
if last_error is not None:
stats.detect_passes = len(stats.detect_attempts)
stats.detect_ms = int((time.perf_counter() - started) * 1000)
raise DetectError(
last_error.code,
last_error.message,
last_error.status_code,
{**(last_error.diagnostics or {}), **stats.public()},
)
stats.detect_passes = len(stats.detect_attempts)
merged = _dedupe_spans(collected)
confirmed = list_confirmed_identities(profile_id) if profile_id else []
mappings, request_local, confirmed_from_spans = _assign_request_tokens(merged, confirmed)

View File

@ -11,6 +11,11 @@ import re
from difflib import SequenceMatcher
from journal_body import plain_text
from journal_generation_policy import (
EMPTY_STYLE_CONTEXT,
STYLE_CONTEXT_KEYS,
normalize_style_context,
)
from writing_profile_schema import is_meta_style_text, recency_weight
from writing_profile_store import (
clip_field,
@ -23,7 +28,7 @@ STYLE_EXAMPLE_MAX = 2
STYLE_EXAMPLE_CHARS = 900
MIN_EXAMPLE_CHARS = 40
GENERATE_SEED_REVISION = "2026-08-27-journal-mixed-sources-v1"
GENERATE_SEED_REVISION = "2026-08-29-voice-legacy-immutable-v1"
EMPTY_STYLE_EXAMPLES = (
"Keine historischen Stilbeispiele. Es gilt nur WRITING_PROFILE "
@ -82,8 +87,18 @@ def style_example_diagnostics(examples: list[dict]) -> dict:
}
def writing_profile_trace(profile_id: str) -> dict:
"""Presence metadata only. No profile text, no labels."""
def writing_profile_trace(
profile_id: str,
*,
style_context: dict | None = None,
brief: str | None = None,
trait_count: int | None = None,
) -> dict:
"""Presence metadata only. No profile text, no labels.
When style_context or a compiled brief is supplied, the flags describe the
effective provider input rather than the stored profile inventory.
"""
from writing_profile_store import (
NEUTRAL_JOURNAL_STYLE,
compile_task_brief,
@ -93,6 +108,7 @@ def writing_profile_trace(profile_id: str) -> dict:
confirmed = has_confirmed_profile(profile_id)
profile = get_profile(profile_id)
context = normalize_style_context(style_context, slot="voice") if style_context is not None else None
core = ((profile.get("core") or {}).get("value") or "").strip()
facet = next(
(
@ -107,15 +123,30 @@ def writing_profile_trace(profile_id: str) -> dict:
for item in profile.get("traits") or []
if item.get("status") == "active" and (item.get("statement") or "").strip()
]
brief = compile_task_brief(profile_id, "journal_generate")
if brief is None:
brief = compile_task_brief(
profile_id,
"journal_generate",
include_core=True if context is None else context["include_core"],
include_facet=True if context is None else context["include_facet"],
include_traits=True if context is None else context["include_traits"],
)
has_core = "Core:" in (brief or "")
has_facet = "Facet-Delta:" in (brief or "") or "Autobiografisches Journaling" in (brief or "")
if trait_count is None:
trait_count = sum(1 for line in (brief or "").splitlines() if line.startswith("- "))
present = bool(brief and brief != NEUTRAL_JOURNAL_STYLE)
return {
"confirmed": confirmed,
"present": bool(confirmed and brief and brief != NEUTRAL_JOURNAL_STYLE),
"present": present,
"neutral_fallback": brief == NEUTRAL_JOURNAL_STYLE,
"has_core": bool(confirmed and core),
"has_facet": bool(confirmed and facet),
"trait_count": len(traits) if confirmed else 0,
"has_core": has_core,
"has_facet": has_facet,
"trait_count": int(trait_count or 0),
"brief_chars": len(brief or ""),
"inventory_has_core": bool(confirmed and core),
"inventory_has_facet": bool(confirmed and facet),
"inventory_trait_count": len(traits) if confirmed else 0,
}
@ -156,6 +187,247 @@ def format_style_examples(examples: list[dict]) -> str:
return "\n".join(part for part in lines if part).strip()
def wrap_style_block(title: str, body: str) -> str:
text = (body or "").strip()
if not text:
return ""
return f"{title}\n{text}\n"
def _estimated_tokens(text: str) -> int:
from prompt_budget import estimate_tokens
body = text or ""
if not body:
return 0
return estimate_tokens(body)
def compile_journal_style_application(
profile_id: str,
style_context: dict | None,
*,
exclude_dates: list[str] | None = None,
exclude_entry_ids: list[str] | None = None,
) -> dict:
"""Select only the style sources released by the voice guideline.
Returns prompt fragments and an effective trace. Empty sections stay empty
so the prompt builder can omit their headings.
"""
from writing_profile_store import (
NEUTRAL_JOURNAL_STYLE,
compile_task_brief,
has_confirmed_profile,
)
context = normalize_style_context(style_context or EMPTY_STYLE_CONTEXT, slot="voice")
confirmed = has_confirmed_profile(profile_id)
omitted: list[dict] = []
requested = {key: bool(context.get(key)) for key in STYLE_CONTEXT_KEYS}
def omit(part: str, reason: str) -> None:
omitted.append({"part": part, "reason": reason})
if not requested["include_core"]:
omit("core", "not_selected")
if not requested["include_facet"]:
omit("facet", "not_selected")
if not requested["include_traits"]:
omit("traits", "not_selected")
if not requested["include_style_examples"]:
omit("style_examples", "not_selected")
wants_profile = any(requested[key] for key in ("include_core", "include_facet", "include_traits"))
brief = compile_task_brief(
profile_id,
"journal_generate",
include_core=requested["include_core"],
include_facet=requested["include_facet"],
include_traits=requested["include_traits"],
)
if wants_profile and not confirmed:
for part, flag in (
("core", "include_core"),
("facet", "include_facet"),
("traits", "include_traits"),
):
if requested[flag] and not any(item["part"] == part and item["reason"] == "unconfirmed" for item in omitted):
omit(part, "unconfirmed")
elif confirmed:
if requested["include_core"] and "Core:" not in (brief or ""):
omit("core", "empty")
if requested["include_facet"] and "Facet-Delta:" not in (brief or "") and "Autobiografisches Journaling" not in (brief or ""):
omit("facet", "empty")
if requested["include_traits"] and not any(line.startswith("- ") for line in (brief or "").splitlines()):
omit("traits", "empty")
example_rows: list[dict] = []
examples_text = ""
if requested["include_style_examples"]:
example_rows = select_journal_style_examples(
profile_id,
exclude_dates=exclude_dates,
exclude_entry_ids=exclude_entry_ids,
)
if example_rows:
examples_text = format_style_examples(example_rows)
else:
omit("style_examples", "empty")
profile_block = wrap_style_block("WRITING_PROFILE", brief)
examples_block = wrap_style_block("STYLE_EXAMPLES", examples_text)
trait_count = sum(1 for line in (brief or "").splitlines() if line.startswith("- "))
core_included = "Core:" in (brief or "")
facet_included = "Facet-Delta:" in (brief or "") or "Autobiografisches Journaling" in (brief or "")
return {
"style_context": context,
"brief": brief,
"profile_block": profile_block,
"style_examples": examples_text,
"examples_block": examples_block,
"example_rows": example_rows,
"omitted": omitted,
"requested": requested,
"effective": {
"include_core": core_included,
"include_facet": facet_included,
"include_traits": trait_count > 0,
"include_style_examples": bool(example_rows),
"trait_count": trait_count,
"style_example_count": len(example_rows),
"neutral_fallback": brief == NEUTRAL_JOURNAL_STYLE,
"blocks": [
{
"key": "writing_profile",
"included": bool(profile_block),
"chars": len(profile_block),
"estimated_tokens": _estimated_tokens(profile_block),
},
{
"key": "core",
"included": core_included,
"chars": len(brief.split("Autobiografisches Journaling")[0]) if core_included else 0,
"estimated_tokens": _estimated_tokens(brief) if core_included and not facet_included and trait_count == 0 else 0,
},
{
"key": "facet",
"included": facet_included,
"chars": 0,
"estimated_tokens": 0,
},
{
"key": "traits",
"included": trait_count > 0,
"count": trait_count,
"chars": 0,
"estimated_tokens": 0,
},
{
"key": "style_examples",
"included": bool(examples_block),
"count": len(example_rows),
"chars": len(examples_block),
"estimated_tokens": _estimated_tokens(examples_block),
},
],
},
}
def style_application_trace(
compiled_policy,
bundle: dict,
*,
dropped: list[str] | None = None,
) -> dict:
dropped = list(dropped or [])
omitted = [dict(item) for item in bundle.get("omitted") or []]
effective = dict(bundle.get("effective") or {})
example_rows = list(bundle.get("example_rows") or [])
profile_block = bundle.get("profile_block") or ""
examples_block = bundle.get("examples_block") or ""
if "style_examples" in dropped:
examples_block = ""
example_rows = []
effective["include_style_examples"] = False
effective["style_example_count"] = 0
if not any(item.get("part") == "style_examples" and item.get("reason") == "budget" for item in omitted):
omitted.append({"part": "style_examples", "reason": "budget"})
for block in effective.get("blocks") or []:
if block.get("key") == "style_examples":
block["included"] = False
block["count"] = 0
block["chars"] = 0
block["estimated_tokens"] = 0
brief = bundle.get("brief") or ""
core_chars = 0
facet_chars = 0
trait_chars = 0
if "Core:" in brief:
core_part = brief.split("Autobiografisches Journaling")[0]
core_chars = len(core_part.strip())
if "Autobiografisches Journaling" in brief:
rest = brief.split("Autobiografisches Journaling", 1)[1]
facet_part = rest.split("\n- ", 1)[0]
facet_chars = len(("Autobiografisches Journaling" + facet_part).strip())
trait_lines = [line for line in brief.splitlines() if line.startswith("- ")]
trait_chars = sum(len(line) for line in trait_lines)
blocks = [
{
"key": "writing_profile",
"included": bool(profile_block.strip()),
"chars": len(profile_block),
"estimated_tokens": _estimated_tokens(profile_block),
},
{
"key": "core",
"included": "Core:" in brief,
"chars": core_chars,
"estimated_tokens": _estimated_tokens(brief.split("\n", 1)[0] if "Core:" in brief else ""),
},
{
"key": "facet",
"included": "Autobiografisches Journaling" in brief,
"chars": facet_chars,
"estimated_tokens": _estimated_tokens("Autobiografisches Journaling" + brief.split("Autobiografisches Journaling", 1)[1].split("\n- ", 1)[0] if "Autobiografisches Journaling" in brief else ""),
},
{
"key": "traits",
"included": bool(trait_lines),
"count": len(trait_lines),
"chars": trait_chars,
"estimated_tokens": _estimated_tokens("\n".join(trait_lines)),
},
{
"key": "style_examples",
"included": bool(examples_block.strip()),
"count": len(example_rows),
"chars": len(examples_block),
"estimated_tokens": _estimated_tokens(examples_block),
},
]
requested = bundle.get("requested") or dict(EMPTY_STYLE_CONTEXT)
return {
"id": compiled_policy.ids.get("voice") if compiled_policy else "",
"key": compiled_policy.keys.get("voice") if compiled_policy else "",
"label": compiled_policy.labels.get("voice") if compiled_policy else "",
"revision": compiled_policy.revisions.get("voice") if compiled_policy else 1,
"cloned_from": (compiled_policy.cloned_from.get("voice") if compiled_policy else "") or "",
"requested": requested,
"effective": {
"include_core": any(block["key"] == "core" and block["included"] for block in blocks),
"include_facet": any(block["key"] == "facet" and block["included"] for block in blocks),
"trait_count": len(trait_lines),
"style_example_count": len(example_rows),
"neutral_fallback": bool((bundle.get("effective") or {}).get("neutral_fallback")),
"blocks": blocks,
},
"omitted": omitted,
"dropped_optional_blocks": dropped,
}
def _content_digest(text: str) -> str:
body = re.sub(r"\s+", " ", plain_text(text or "")).strip().lower()
return hashlib.sha256(body.encode("utf-8")).hexdigest()

View File

@ -11,11 +11,11 @@ from journal_policy import require_explicit_generate, source_conversation_ids
from identity_store import is_maskable_label, list_mappings, mapping_spellings
from journal_body import clean_title
from journal_editorial import (
format_style_examples,
compile_journal_style_application,
incomplete_syntax_markers,
lexical_similarity,
narration_sources_text,
select_journal_style_examples,
style_application_trace,
style_example_diagnostics,
writing_profile_trace,
)
@ -26,6 +26,7 @@ from journal_generation_policy import (
draft_snapshot,
mark_guidelines_used,
policy_trace,
prompt_without_orphaned_style_example_rule,
resolve_run_selection,
)
from journal_reconstruct import (
@ -45,6 +46,7 @@ from prompt_budget import (
from privacy_gateway import (
GENERIC_PLACEHOLDER_INNER,
canonical_token,
compact_diagnostics,
identity_label_pattern,
identity_occurrence_count,
is_identity_mention,
@ -201,6 +203,8 @@ def _compose_journal_trace(
narrate_trace["writing_profile"] = profile_meta
narrate_trace["style_examples"] = {**style_meta, "dropped": "style_examples" in dropped}
narrate_trace["dropped_optional_blocks"] = dropped
if extra and extra.get("style_application"):
narrate_trace["style_application"] = extra["style_application"]
diag = narrate_result.get("diagnostics") or {}
if diag.get("generate_ms") is not None:
narrate_trace["generate_ms"] = diag.get("generate_ms")
@ -221,8 +225,9 @@ def _compose_journal_trace(
"prompt_revision": narrate_trace.get("prompt_revision"),
"writing_profile": profile_meta,
"style_examples": narrate_trace.get("style_examples"),
"style_application": narrate_trace.get("style_application") or (extra or {}).get("style_application"),
"dropped_optional_blocks": dropped,
"log": run_log,
"log": [dict(item) if isinstance(item, dict) else item for item in run_log],
"stages": [reconstruct_trace, narrate_trace],
}
@ -264,11 +269,17 @@ def _raise_budget(exc: JournalBudgetError) -> None:
def _stage_trace(result: dict, fallback_purpose: str) -> dict:
trace = dict(result.get("trace") or {})
if result.get("diagnostics"):
trace["budget"] = result.get("diagnostics")
elif not trace.get("budget"):
source = result.get("trace")
trace = dict(source) if isinstance(source, dict) else {}
budget_src = result.get("diagnostics")
if not isinstance(budget_src, dict):
budget_src = trace.get("budget") if isinstance(trace.get("budget"), dict) else None
if isinstance(budget_src, dict):
trace["budget"] = compact_diagnostics(budget_src)
else:
trace["budget"] = None
if "log" in trace and isinstance(trace["log"], list):
trace["log"] = [dict(item) if isinstance(item, dict) else item for item in trace["log"]]
trace["purpose"] = trace.get("purpose") or fallback_purpose
return trace
@ -333,7 +344,11 @@ def pack_narration_context(
candidate = dict(assembled)
candidate["style_examples"] = examples
candidate["existing_text"] = existing
rendered = preview_prompt(prompt, candidate)["rendered"]
fit_prompt = prompt_without_orphaned_style_example_rule(
prompt,
include_style_examples=bool((examples or "").strip()),
)
rendered = preview_prompt(fit_prompt, candidate)["rendered"]
try:
assert_input_fits(budget, rendered)
if not examples and style_examples:
@ -464,12 +479,20 @@ def _generate_draft(
source=persist_meta["source"],
remembered=persist_meta["remembered"],
)
profile_meta = writing_profile_trace(profile_id)
style_example_rows = select_journal_style_examples(
style_bundle = compile_journal_style_application(
profile_id,
compiled_policy.style_context,
exclude_dates=[day.get("calendar_date") or ""],
)
style_examples = format_style_examples(style_example_rows)
profile_meta = writing_profile_trace(
profile_id,
style_context=compiled_policy.style_context,
brief=style_bundle.get("brief") or "",
trait_count=(style_bundle.get("effective") or {}).get("trait_count"),
)
style_example_rows = list(style_bundle.get("example_rows") or [])
style_examples = style_bundle.get("examples_block") or ""
writing_profile = style_bundle.get("profile_block") or ""
style_meta = style_example_diagnostics(style_example_rows)
run_log: list[dict] = []
_event(
@ -503,6 +526,7 @@ def _generate_draft(
existing_text=existing_text,
conversation_id=selected[0] if selected else None,
reconstruction=narration_sources_text(reconstruction),
writing_profile=writing_profile,
style_examples=style_examples,
transformation_instructions=compiled_policy.instructions["transformation_instructions"],
detail_instructions=compiled_policy.instructions["detail_instructions"],
@ -523,11 +547,21 @@ def _generate_draft(
_raise_budget(exc)
if dropped:
_event(run_log, "journal_generate", "budget_pack", dropped=",".join(dropped))
style_app = style_application_trace(compiled_policy, style_bundle, dropped=dropped)
if "style_examples" in dropped:
style_meta = {**style_example_diagnostics([]), "dropped": True, "dropped_reason": "budget"}
else:
style_meta = {**style_meta, "dropped": False}
profile_meta["brief_chars"] = len((assembled.get("writing_profile") or "").strip())
mappings = list_mappings(profile_id)
preview_title, preview_body = _local_narration(reconstruction)
source_preview = f"{preview_title}\n\n{preview_body}".strip()
narrate_result: dict = {}
try:
narrate_prompt = prompt_without_orphaned_style_example_rule(
narrate_prompt,
include_style_examples=bool((assembled.get("style_examples") or "").strip()),
)
rendered_preview = preview_prompt(narrate_prompt, assembled)
assert_template_resolved(rendered_preview.get("rendered") or "")
narrate_result = execute_prompt(
@ -575,6 +609,7 @@ def _generate_draft(
"abort_reason": unattested,
"source_preview": source_preview,
"generation_selection": policy_meta,
"style_application": style_app,
},
)
_reject_generation(
@ -622,6 +657,7 @@ def _generate_draft(
"abort_reason": exc.code,
"source_preview": source_preview,
"generation_selection": policy_meta,
"style_application": style_app,
},
)
diag["log"] = run_log
@ -700,6 +736,7 @@ def _generate_draft(
"lexical_similarity": lexical_similarity("\n".join(source_user), body),
"incomplete_syntax": incomplete_syntax_markers(body),
"generation_selection": policy_meta,
"style_application": style_app,
"stored_title": title,
"stored_body": body,
"reply": stored,

View File

@ -63,6 +63,46 @@ MAX_LABEL_CHARS = 80
MAX_SUMMARY_CHARS = 160
KEY_RE = re.compile(r"^[a-z][a-z0-9_]{0,40}$")
SEED_PATH = Path(__file__).resolve().parent / "config" / "generation_instructions.seed.json"
STYLE_CONTEXT_KEYS = (
"include_core",
"include_facet",
"include_traits",
"include_style_examples",
)
EMPTY_STYLE_CONTEXT = {key: False for key in STYLE_CONTEXT_KEYS}
FULL_STYLE_CONTEXT = {key: True for key in STYLE_CONTEXT_KEYS}
LEGACY_VOICE_STYLE_CONTEXT = {
"neutral": dict(EMPTY_STYLE_CONTEXT),
"light": {**EMPTY_STYLE_CONTEXT, "include_core": True},
"noticeable": {
"include_core": True,
"include_facet": True,
"include_traits": True,
"include_style_examples": False,
},
"clear": {
"include_core": True,
"include_facet": True,
"include_traits": True,
"include_style_examples": False,
},
"with_examples": dict(FULL_STYLE_CONTEXT),
"legacy_neutral": dict(FULL_STYLE_CONTEXT),
"legacy_light": dict(FULL_STYLE_CONTEXT),
"legacy_noticeable": dict(FULL_STYLE_CONTEXT),
"legacy_clear": dict(FULL_STYLE_CONTEXT),
}
LEGACY_COMPARISON_KEYS = frozenset(
{
"legacy_neutral",
"legacy_light",
"legacy_noticeable",
"legacy_clear",
}
)
STYLE_EXAMPLES_STANDING_SENTENCE = (
"STYLE_EXAMPLES dienen ausschließlich als Stilreferenz; ihre Inhalte gehören nicht zum heutigen Tag. "
)
PUBLIC_FIELDS = (
"id",
"purpose",
@ -108,6 +148,8 @@ class CompiledPolicy:
revisions: dict[str, int]
instructions: dict[str, str]
seed_revision: str
style_context: dict[str, bool]
cloned_from: dict[str, str]
def load_seed_document() -> dict:
@ -118,6 +160,95 @@ def _as_bool(raw) -> bool:
return bool(int(raw)) if not isinstance(raw, bool) else raw
def default_style_context_for_key(guideline_key: str, *, slot: str) -> dict[str, bool]:
if slot != "voice":
return {}
mapped = LEGACY_VOICE_STYLE_CONTEXT.get((guideline_key or "").strip())
if mapped:
return dict(mapped)
return dict(FULL_STYLE_CONTEXT)
def normalize_style_context(raw, *, slot: str, required: bool = False) -> dict[str, bool]:
if slot != "voice":
if raw in (None, "", {}, []):
return {}
raise CatalogError(
"Stilkontext gehört nur zur Dimension Persönliche Stimme.",
code="invalid_generation_guideline",
status_code=400,
)
if raw in (None, "", {}):
if required:
raise CatalogError(
"Die Stilanwendung braucht eine Stilkontext-Konfiguration.",
code="invalid_generation_guideline",
status_code=400,
)
return dict(EMPTY_STYLE_CONTEXT)
if not isinstance(raw, dict):
raise CatalogError(
"style_context muss ein Objekt mit den vier Stilquellen sein.",
code="invalid_generation_guideline",
status_code=400,
)
unknown = sorted(set(raw) - set(STYLE_CONTEXT_KEYS))
if unknown:
raise CatalogError(
"Unbekannte Stilquellen: " + ", ".join(unknown),
code="invalid_generation_guideline",
status_code=400,
)
missing = [key for key in STYLE_CONTEXT_KEYS if key not in raw]
if missing and required:
raise CatalogError(
"style_context ist unvollständig: " + ", ".join(missing),
code="invalid_generation_guideline",
status_code=400,
)
parsed: dict[str, bool] = dict(EMPTY_STYLE_CONTEXT)
for key in STYLE_CONTEXT_KEYS:
if key not in raw:
continue
value = raw[key]
if isinstance(value, bool):
parsed[key] = value
elif value in (0, 1):
parsed[key] = bool(value)
else:
raise CatalogError(
f"{key} muss wahr oder falsch sein, keine Gewichtung.",
code="invalid_generation_guideline",
status_code=400,
)
return parsed
def parse_style_context_json(raw, *, slot: str, guideline_key: str = "") -> dict[str, bool]:
if slot != "voice":
return {}
text = (raw or "").strip()
if not text or text in ("{}", "null"):
return default_style_context_for_key(guideline_key, slot=slot)
try:
data = json.loads(text)
except json.JSONDecodeError:
return default_style_context_for_key(guideline_key, slot=slot)
if not isinstance(data, dict) or not any(key in data for key in STYLE_CONTEXT_KEYS):
return default_style_context_for_key(guideline_key, slot=slot)
try:
return normalize_style_context(data, slot=slot, required=False)
except CatalogError:
return default_style_context_for_key(guideline_key, slot=slot)
def style_context_json(context: dict[str, bool] | None, *, slot: str) -> str:
if slot != "voice":
return "{}"
payload = normalize_style_context(context or EMPTY_STYLE_CONTEXT, slot=slot, required=False)
return json.dumps(payload, ensure_ascii=False, sort_keys=True)
def _limits() -> tuple[int, int, int]:
seed = load_seed_document()
return (
@ -156,6 +287,11 @@ def _row(item: dict | None, *, include_instruction: bool = False) -> dict | None
payload["revision"] = int(item.get("revision") or 1)
payload["sort_order"] = int(item.get("sort_order") or 0)
payload["guideline_key"] = item.get("guideline_key") or ""
payload["style_context"] = parse_style_context_json(
item.get("style_context_json"),
slot=item.get("slot") or "",
guideline_key=payload["guideline_key"],
)
if include_instruction:
payload["instruction"] = item.get("instruction") or ""
payload["seed_id"] = item.get("seed_id") or ""
@ -222,6 +358,7 @@ def active_user_options(purpose: str = PURPOSE_JOURNAL) -> dict[str, list[dict]]
"summary": item["summary"],
"revision": item["revision"],
"is_default": item["is_default"],
"style_context": item.get("style_context") or {},
}
for item in items
if item["slot"] == slot
@ -296,6 +433,12 @@ def compile_selection(
revisions={slot: int(chosen[slot].get("revision") or 1) for slot in USER_SLOTS},
instructions=instructions,
seed_revision=revision,
style_context=parse_style_context_json(
chosen["voice"].get("style_context_json"),
slot="voice",
guideline_key=chosen["voice"].get("guideline_key") or "",
),
cloned_from={slot: chosen[slot].get("cloned_from") or "" for slot in USER_SLOTS},
)
@ -313,6 +456,8 @@ def policy_trace(
"labels": dict(compiled.labels),
"revisions": dict(compiled.revisions),
"seed_revision": compiled.seed_revision,
"style_context": dict(compiled.style_context),
"cloned_from": dict(compiled.cloned_from),
}
@ -336,6 +481,8 @@ def draft_snapshot(compiled: CompiledPolicy, *, prompt: dict | None = None, mode
"key": compiled.keys["voice"],
"label": compiled.labels["voice"],
"revision": compiled.revisions["voice"],
"cloned_from": compiled.cloned_from.get("voice") or "",
"style_context": dict(compiled.style_context),
},
"narrative": {
"id": compiled.ids["narrative"],
@ -397,6 +544,25 @@ def assert_template_resolved(rendered: str) -> None:
)
def omit_orphaned_style_example_rule(template: str) -> str:
"""Drop the standing STYLE_EXAMPLES sentence when no example block is sent."""
text = template or ""
return text.replace(STYLE_EXAMPLES_STANDING_SENTENCE, "").replace(
"STYLE_EXAMPLES dienen ausschließlich als Stilreferenz; ihre Inhalte gehören nicht zum heutigen Tag.",
"",
)
def prompt_without_orphaned_style_example_rule(prompt: dict, *, include_style_examples: bool) -> dict:
if include_style_examples:
return prompt
template = prompt.get("template") or ""
cleaned = omit_orphaned_style_example_rule(template)
if cleaned == template:
return prompt
return {**prompt, "template": cleaned}
def load_selection(profile_id: str) -> dict[str, str] | None:
with get_db() as conn:
row = row_to_dict(
@ -497,21 +663,28 @@ def mark_guidelines_used(ids: list[str]) -> None:
)
def _write_fields(raw: dict) -> dict:
def _write_fields(raw: dict, *, slot: str) -> dict:
max_instruction, max_label, max_summary = _limits()
style_context = normalize_style_context(
raw.get("style_context"),
slot=slot,
required=False,
)
return {
"guideline_key": _clean_key(raw.get("guideline_key") or raw.get("key")),
"label": _clean_text(raw.get("label"), "label", max_chars=max_label),
"summary": _clean_text(raw.get("summary"), "summary", max_chars=max_summary, allow_empty=True),
"instruction": _clean_text(raw.get("instruction"), "instruction", max_chars=max_instruction),
"sort_order": int(raw.get("sort_order") or 0),
"style_context": style_context,
"style_context_json": style_context_json(style_context, slot=slot),
}
def create_guideline(purpose: str, slot: str, body: dict) -> dict:
if slot not in ALLOWED_SLOTS:
raise CatalogError("Unbekannter Slot.", code="invalid_generation_guideline", status_code=400)
fields = _write_fields(body)
fields = _write_fields(body, slot=slot)
guideline_id = str(uuid.uuid4())
with get_db() as conn:
count = conn.execute(
@ -521,11 +694,11 @@ def create_guideline(purpose: str, slot: str, body: dict) -> dict:
conn.execute(
"""
INSERT INTO generation_guidelines (
id, purpose, slot, guideline_key, label, summary, instruction, sort_order,
id, purpose, slot, guideline_key, label, summary, instruction, style_context_json, sort_order,
status, revision, cloned_from, is_default, is_system_seed, seed_id, seed_revision,
created, updated
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, NULL, 0, 0, '', '', datetime('now'), datetime('now'))
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, NULL, 0, 0, '', '', datetime('now'), datetime('now'))
""",
(
guideline_id,
@ -535,6 +708,7 @@ def create_guideline(purpose: str, slot: str, body: dict) -> dict:
fields["label"],
fields["summary"],
fields["instruction"],
fields["style_context_json"],
fields["sort_order"] if body.get("sort_order") is not None else int(count) + 1,
STATUS_DRAFT,
),
@ -557,11 +731,11 @@ def clone_guideline(guideline_id: str) -> dict:
conn.execute(
"""
INSERT INTO generation_guidelines (
id, purpose, slot, guideline_key, label, summary, instruction, sort_order,
id, purpose, slot, guideline_key, label, summary, instruction, style_context_json, sort_order,
status, revision, cloned_from, is_default, is_system_seed, seed_id, seed_revision,
created, updated
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, '', ?, datetime('now'), datetime('now'))
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, '', ?, datetime('now'), datetime('now'))
""",
(
new_id,
@ -571,6 +745,14 @@ def clone_guideline(guideline_id: str) -> dict:
current["label"],
current["summary"],
current["instruction"],
current.get("style_context_json") or style_context_json(
parse_style_context_json(
current.get("style_context_json"),
slot=current.get("slot") or "",
guideline_key=current.get("guideline_key") or "",
),
slot=current.get("slot") or "",
),
int(current.get("sort_order") or 0),
STATUS_DRAFT,
int(current.get("revision") or 1) + 1,
@ -591,13 +773,20 @@ def update_guideline(guideline_id: str, body: dict) -> dict:
code="guideline_immutable",
status_code=409,
)
fields = _write_fields({**current, **body, "guideline_key": body.get("guideline_key") or current["guideline_key"]})
merged = {**current, **body, "guideline_key": body.get("guideline_key") or current["guideline_key"]}
if "style_context" not in body:
merged["style_context"] = parse_style_context_json(
current.get("style_context_json"),
slot=current.get("slot") or "",
guideline_key=current.get("guideline_key") or "",
)
fields = _write_fields(merged, slot=current.get("slot") or "")
with get_db() as conn:
conn.execute(
"""
UPDATE generation_guidelines
SET guideline_key = ?, label = ?, summary = ?, instruction = ?, sort_order = ?,
updated = datetime('now')
SET guideline_key = ?, label = ?, summary = ?, instruction = ?, style_context_json = ?,
sort_order = ?, updated = datetime('now')
WHERE id = ?
""",
(
@ -605,6 +794,7 @@ def update_guideline(guideline_id: str, body: dict) -> dict:
fields["label"],
fields["summary"],
fields["instruction"],
fields["style_context_json"],
fields["sort_order"],
guideline_id,
),
@ -622,6 +812,16 @@ def publish_guideline(guideline_id: str) -> dict:
_clean_text(current.get("label"), "label", max_chars=max_label)
_clean_text(current.get("summary"), "summary", max_chars=max_summary, allow_empty=True)
_clean_text(current.get("instruction"), "instruction", max_chars=max_instruction)
if current.get("slot") == "voice":
normalize_style_context(
parse_style_context_json(
current.get("style_context_json"),
slot="voice",
guideline_key=current.get("guideline_key") or "",
),
slot="voice",
required=True,
)
with get_db() as conn:
conn.execute(
"UPDATE generation_guidelines SET status = ?, updated = datetime('now') WHERE id = ?",
@ -711,6 +911,7 @@ def reset_seed_drafts(purpose: str = PURPOSE_JOURNAL) -> dict:
"summary": item.get("summary") or "",
"instruction": item.get("instruction") or "",
"sort_order": index,
"style_context": item.get("style_context") or {},
},
)
)
@ -725,12 +926,17 @@ def preview_selection(selection: dict[str, str], *, purpose: str = PURPOSE_JOURN
compiled = compile_selection(selection, purpose=purpose)
prompt = load_active_prompt("mvp.journal_generate")
assert_journal_prompt_contract(prompt.get("template") or "")
include_examples = bool(compiled.style_context.get("include_style_examples"))
prompt = prompt_without_orphaned_style_example_rule(
prompt,
include_style_examples=include_examples,
)
rendered = preview_prompt(
prompt,
{
**compiled.instructions,
"writing_profile": "(nicht enthalten)",
"style_examples": "(nicht enthalten)",
"style_examples": "(Stilbeispiele)" if include_examples else "",
"reconstruction": "(nicht enthalten)",
"existing_text": "",
},
@ -746,6 +952,8 @@ def preview_selection(selection: dict[str, str], *, purpose: str = PURPOSE_JOURN
"keys": dict(compiled.keys),
"labels": dict(compiled.labels),
"revisions": dict(compiled.revisions),
"cloned_from": dict(compiled.cloned_from),
"style_context": dict(compiled.style_context),
},
"seed_revision": compiled.seed_revision,
}
@ -758,6 +966,7 @@ def _seed_rows(seed: dict, purpose: str) -> list[tuple]:
for slot in ALLOWED_SLOTS:
variants = (slots.get(slot) or {}).get("variants") or []
for index, item in enumerate(variants):
context = normalize_style_context(item.get("style_context") or {}, slot=slot, required=False)
rows.append(
(
item.get("id") or str(uuid.uuid4()),
@ -767,6 +976,7 @@ def _seed_rows(seed: dict, purpose: str) -> list[tuple]:
item.get("label") or "",
item.get("summary") or "",
item.get("instruction") or "",
style_context_json(context, slot=slot),
index,
STATUS_ACTIVE,
1,
@ -779,9 +989,132 @@ def _seed_rows(seed: dict, purpose: str) -> list[tuple]:
return rows
def seed_generation_instructions(conn) -> None:
"""Insert missing seed guidelines. Never overwrite published or admin-created rows."""
seed = load_seed_document()
def _semantic_signature(item: dict | None = None, *, seed_row: tuple | None = None) -> tuple:
if seed_row is not None:
slot = seed_row[2] or ""
key = seed_row[3] or ""
return (
key,
seed_row[4] or "",
seed_row[5] or "",
seed_row[6] or "",
style_context_json(
parse_style_context_json(seed_row[7], slot=slot, guideline_key=key),
slot=slot,
),
)
slot = (item or {}).get("slot") or ""
key = (item or {}).get("guideline_key") or ""
return (
key,
(item or {}).get("label") or "",
(item or {}).get("summary") or "",
(item or {}).get("instruction") or "",
style_context_json(
parse_style_context_json((item or {}).get("style_context_json"), slot=slot, guideline_key=key),
slot=slot,
),
)
def _successor_id(base_id: str, revision: int) -> str:
root = re.sub(r"-r\d+$", "", base_id or "")
return f"{root}-r{int(revision)}"
def _catalog_maps(conn, purpose: str) -> tuple[dict, dict]:
rows = [row_to_dict(row) for row in conn.execute(
"SELECT * FROM generation_guidelines WHERE purpose = ?",
(purpose,),
).fetchall()]
by_id = {row["id"]: row for row in rows}
by_seed_id = {row["seed_id"]: row for row in rows if row.get("seed_id")}
return by_id, by_seed_id
def _find_existing_seed_row(by_id: dict, by_seed_id: dict, seed_row: tuple, all_rows: list[dict]) -> dict | None:
current = by_id.get(seed_row[0]) or by_seed_id.get(seed_row[13])
if current:
return current
key = seed_row[3] or ""
slot = seed_row[2] or ""
if key not in LEGACY_COMPARISON_KEYS:
return None
for item in all_rows:
if item.get("slot") == slot and item.get("guideline_key") == key:
return item
return None
def _find_matching_successor(all_rows: list[dict], predecessor_id: str, seed_row: tuple) -> dict | None:
wanted = _semantic_signature(seed_row=seed_row)
for item in all_rows:
if (item.get("cloned_from") or "") != predecessor_id:
continue
if _semantic_signature(item) == wanted:
return item
return None
def _insert_seed_guideline(
conn,
seed_row: tuple,
*,
guideline_id: str | None = None,
cloned_from: str | None = None,
revision: int | None = None,
is_default: int | None = None,
) -> None:
row_id = guideline_id or seed_row[0]
seed_id = row_id if guideline_id else seed_row[13]
conn.execute(
"""
INSERT INTO generation_guidelines (
id, purpose, slot, guideline_key, label, summary, instruction, style_context_json, sort_order,
status, revision, cloned_from, is_default, is_system_seed, seed_id, seed_revision,
created, updated
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
""",
(
row_id,
seed_row[1],
seed_row[2],
seed_row[3],
seed_row[4],
seed_row[5],
seed_row[6],
seed_row[7],
seed_row[8],
STATUS_ACTIVE,
int(revision if revision is not None else seed_row[10] or 1),
cloned_from,
int(is_default if is_default is not None else seed_row[11] or 0),
1,
seed_id,
seed_row[14],
),
)
def _assign_slot_default(conn, purpose: str, slot: str, guideline_id: str) -> None:
conn.execute(
"""
UPDATE generation_guidelines
SET is_default = CASE WHEN id = ? THEN 1 ELSE 0 END, updated = datetime('now')
WHERE purpose = ? AND slot = ? AND status = ?
""",
(guideline_id, purpose, slot, STATUS_ACTIVE),
)
def seed_generation_instructions(conn, seed: dict | None = None) -> None:
"""Insert missing seed guidelines. Never rewrite existing IDs, clones, or archives.
A semantic change in the seed creates a new successor ID. New defaults may
point at that successor. Stored selections keep their previous IDs.
"""
seed = seed if seed is not None else load_seed_document()
purpose = seed.get("purpose") or PURPOSE_JOURNAL
conn.execute(
"""
@ -791,34 +1124,42 @@ def seed_generation_instructions(conn) -> None:
""",
(STATUS_ARCHIVED, purpose, LEGACY_SOURCE_MODE_SLOT, STATUS_ARCHIVED),
)
existing = {
row["id"]
for row in conn.execute("SELECT id FROM generation_guidelines WHERE purpose = ?", (purpose,)).fetchall()
}
existing_seed_ids = {
row["seed_id"]
for row in conn.execute(
"SELECT seed_id FROM generation_guidelines WHERE purpose = ? AND seed_id != ''",
(purpose,),
).fetchall()
if row["seed_id"]
}
for row in _seed_rows(seed, purpose):
row_id = row[0]
seed_id = row[12]
if row_id in existing or seed_id in existing_seed_ids:
continue
conn.execute(
"""
INSERT INTO generation_guidelines (
id, purpose, slot, guideline_key, label, summary, instruction, sort_order,
status, revision, cloned_from, is_default, is_system_seed, seed_id, seed_revision,
created, updated
by_id, by_seed_id = _catalog_maps(conn, purpose)
all_rows = list(by_id.values())
current = _find_existing_seed_row(by_id, by_seed_id, row, all_rows)
if current:
if not _as_bool(current.get("is_system_seed", 0)):
continue
if current.get("status") != STATUS_ACTIVE:
continue
if _semantic_signature(current) == _semantic_signature(seed_row=row):
continue
successor = _find_matching_successor(all_rows, current["id"], row)
if successor:
if row[11] and successor.get("status") == STATUS_ACTIVE:
_assign_slot_default(conn, purpose, row[2], successor["id"])
continue
next_revision = int(current.get("revision") or 1) + 1
taken = set(by_id)
successor_id = _successor_id(current["id"], next_revision)
while successor_id in taken:
next_revision += 1
successor_id = _successor_id(current["id"], next_revision)
_insert_seed_guideline(
conn,
row,
guideline_id=successor_id,
cloned_from=current["id"],
revision=next_revision,
is_default=int(row[11] or 0),
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, datetime('now'), datetime('now'))
""",
row,
)
if row[11]:
_assign_slot_default(conn, purpose, row[2], successor_id)
continue
_insert_seed_guideline(conn, row)
if row[11]:
_assign_slot_default(conn, purpose, row[2], row[0])
def backfill_missing_settings(conn) -> None:

View File

@ -1,12 +1,15 @@
"""Journal-specific first impulse. Not a generic continuation or intent engine.
Uses the existing Privacy Gateway, dialogue prompt, guards and request-scoped
trace. Attested plans and open day points come only from user-role sources.
Assistant text is conversation context, never a user fact. Recency is not a pattern.
trace. Attested plans come only from yesterday's user-role dialogue. Open day
points stay on the same journal day. Saved journal entries are completed
narrative, never an opening plan. Assistant text is conversation context, never
a user fact. Recency is not a pattern.
"""
from __future__ import annotations
import re
from datetime import date, timedelta
from context_builder import assemble_text, build_internal_context
from debug_store import persist_engine_error, persist_step
@ -20,11 +23,14 @@ from retrieval import retrieve
NEUTRAL_OPENING = "Wenn du magst, fang einfach an ich höre zu."
OPENING_POLICY = (
"Dies ist der erste Impuls dieses Gesprächs. Es gibt hier noch keine Nutzerzeile. "
"Nur belegte Vorhaben aus user-Quellen oder offene user-Punkte desselben Journal Day aufgreifen. "
"Nur belegte Vorhaben aus dem user-Dialog des unmittelbar vorigen Kalendertags "
"oder offene user-Punkte desselben Journal Day aufgreifen. "
"Ein gespeicherter Journal-Eintrag ist abgeschlossene Erzählung, kein offenes Vorhaben. "
"Ein Recency-Treffer ist kein bewiesenes wiederkehrendes Muster. "
"Nicht erwähnt ist nicht geschehen. Ein Plan ist kein Vollzug. "
"Assistententext ist Gesprächskontext, kein Nutzerfakt. "
"Keine Behauptung, ein Vorhaben sei ausgeführt worden."
"Keine Behauptung, ein Vorhaben sei ausgeführt worden. "
"Keine bereits erzählte, abgeschlossene Episode eines früheren Tags als offenen Faden führen."
)
FUTURE_INTENT = re.compile(
@ -46,6 +52,22 @@ UNEARNED_PATTERN = re.compile(
)
def parse_calendar_date(value: str | None) -> date | None:
raw = (value or "").strip()
if not raw:
return None
try:
return date.fromisoformat(raw)
except ValueError:
return None
def is_previous_calendar_day(source: date | None, current: date | None) -> bool:
if source is None or current is None:
return False
return source == current - timedelta(days=1)
def is_attested_plan(text: str) -> bool:
"""User wording that marks a plan or intention. Not past completion, not overlap."""
body = (text or "").strip()
@ -88,7 +110,8 @@ def collect_opening_context(profile_id: str, conversation_id: str) -> dict:
journal_day_id = conversation.get("journal_day_id")
if not space_id or not journal_day_id:
raise StoreError("not_journal_conversation", "Erster Impuls nur für einen Journal-Dialog.")
get_day(profile_id, journal_day_id)
day = get_day(profile_id, journal_day_id)
current_date = parse_calendar_date(day.get("calendar_date"))
day_conversations = list_conversations_for_day(profile_id, journal_day_id)
open_points: list[str] = []
@ -114,6 +137,7 @@ def collect_opening_context(profile_id: str, conversation_id: str) -> dict:
},
)
recency_user: list[str] = []
yesterday_user: list[str] = []
for entry in prior_entries:
excerpt = (entry.get("excerpt") or "").strip()
if excerpt:
@ -122,15 +146,19 @@ def collect_opening_context(profile_id: str, conversation_id: str) -> dict:
excerpt = (source.get("excerpt") or "").strip()
if excerpt:
recency_user.append(excerpt)
if excerpt and is_previous_calendar_day(
parse_calendar_date(source.get("calendar_date")),
current_date,
):
yesterday_user.append(excerpt)
user_pool = [*open_points, *recency_user]
plans = attested_plans_from_user_texts(user_pool)
plans = attested_plans_from_user_texts(yesterday_user)
has_relevant = bool(plans or open_points)
return {
"space_id": space_id,
"journal_day_id": journal_day_id,
"conversation_id": conversation_id,
"user_texts": user_pool,
"user_texts": [*open_points, *yesterday_user],
"assistant_texts": day_assistant,
"attested_plans": plans,
"open_day_points": open_points,
@ -230,13 +258,15 @@ def start_journal_opening(profile_id: str, conversation_id: str) -> dict:
journal_day_id=conversation.get("journal_day_id"),
purpose="dialogue_turn",
conversation_ids=day_ids,
include_space_recency=False,
)
assembled = assemble_text(context)
assembled = dict(assembled)
assembled["opening_hint"] = format_opening_hint(facts)
assembled["register_hint"] = (
"Erster Impuls. Noch keine Nutzerzeile in diesem Gespräch. "
"Nur belegte Vorhaben oder offene user-Punkte. Kein Muster aus Recency."
"Nur Vorhaben aus dem user-Dialog von gestern oder offene user-Punkte dieses Tags. "
"Kein Muster aus Recency. Keine abgeschlossene frühere Episode wieder öffnen."
)
prompt = load_active_prompt("mvp.dialogue_turn")
calls = 0

View File

@ -85,6 +85,20 @@ COMPACT_DIAGNOSTIC_KEYS = (
"detect_cost",
"detect_ms",
"generate_called",
"generate_requested",
"http_status",
"provider_message",
"provider_code",
"timeout_s",
"cost_report",
"detect_passes",
"detect_partial_discarded",
"detect_attempts",
"contract_violation",
"invalid_entity_type",
"detect_cost_unknown",
"generation_selection",
"style_application",
)
ERROR_EGRESS_VALIDATION = "egress_validation_failed"
@ -165,14 +179,159 @@ class MaskingManifest:
}
def _snapshot_plain(value: Any, *, _stack: set[int] | None = None) -> Any:
"""Acyclic copy of compact diagnostic values. Drops cyclic back-references."""
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, (dict, list, tuple)):
stack = _stack or set()
ident = id(value)
if ident in stack:
return None
stack = set(stack)
stack.add(ident)
if isinstance(value, dict):
return {
str(key): _snapshot_plain(item, _stack=stack)
for key, item in value.items()
if key not in {"trace", "diagnostics", "intern", "egress", "raw", "reply", "prompt", "messages"}
}
return [_snapshot_plain(item, _stack=stack) for item in value]
return value
def compact_diagnostics(data: dict[str, Any] | None) -> dict[str, Any]:
payload = {}
for key in COMPACT_DIAGNOSTIC_KEYS:
if data and key in data and data[key] is not None:
payload[key] = data[key]
payload[key] = _snapshot_plain(data[key])
return payload
def _as_cost(value: Any) -> float | None:
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _format_usd(value: float) -> str:
text = f"{value:.6f}".rstrip("0").rstrip(".")
return f"{text.replace('.', ',')} USD"
def _first_present(*values: Any) -> Any:
for value in values:
if value is not None:
return value
return None
def public_cost_report(diagnostics: dict[str, Any] | None) -> dict[str, Any]:
"""User-facing billed/unbilled summary. No prompts, no mapping labels."""
data = diagnostics or {}
nested = data.get("trace") if isinstance(data.get("trace"), dict) else {}
budget = nested.get("budget") if isinstance(nested.get("budget"), dict) else {}
generate_called = bool(
data.get("generate_called")
or data.get("generate_calls")
or nested.get("generate_called")
or nested.get("generate_calls")
)
generate_requested = bool(
data.get("generate_requested")
or nested.get("generate_requested")
or generate_called
)
detect_calls = _first_present(data.get("detect_calls"), nested.get("detect_calls"))
try:
detect_calls_n = int(detect_calls) if detect_calls is not None else 0
except (TypeError, ValueError):
detect_calls_n = 0
detect_called = detect_calls_n > 0
generate_cost = _as_cost(_first_present(data.get("cost"), budget.get("cost")))
detect_cost = _as_cost(_first_present(data.get("detect_cost"), nested.get("detect_cost")))
known = [item for item in (detect_cost, generate_cost) if item is not None]
total = sum(known) if known else None
if (detect_cost or 0) > 0 or (generate_cost or 0) > 0:
billed = "yes"
elif generate_requested and generate_cost is None:
billed = "unknown"
elif detect_called and detect_cost is None and not generate_called:
billed = "unknown"
else:
billed = "no"
parts: list[str] = []
if detect_called or detect_cost is not None:
if detect_cost is not None:
parts.append(f"Detect {_format_usd(detect_cost)}")
elif detect_called:
parts.append("Detect aufgerufen, Kosten unbekannt")
if generate_called and generate_cost is not None:
parts.append(f"Generate {_format_usd(generate_cost)}")
elif generate_called:
parts.append("Generate abgeschlossen, Kosten unbekannt")
elif generate_requested:
parts.append("Generate-Aufruf gesendet, Kosten unbekannt")
else:
parts.append("Generate nicht aufgerufen")
if billed == "yes":
note = "Beim Anbieter sind Kosten entstanden"
if parts:
note += ": " + "; ".join(parts) + "."
else:
note += "."
elif billed == "unknown":
note = "Ob beim Anbieter Kosten entstanden sind, ist unbekannt"
if parts:
note += " (" + "; ".join(parts) + ")."
else:
note += "."
else:
if detect_called or generate_requested or generate_called:
note = "Beim Anbieter sind für diesen Lauf keine Kosten ausgewiesen"
if parts:
note += " (" + "; ".join(parts) + ")."
else:
note += "."
else:
note = "Es wurde kein Provider aufgerufen. Es sind keine Kosten entstanden."
return {
"generate_called": generate_called,
"generate_requested": generate_requested,
"detect_called": detect_called,
"detect_calls": detect_calls_n,
"cost": generate_cost,
"detect_cost": detect_cost,
"total_cost": total,
"billed": billed,
"note": note,
}
def public_error_detail(exc) -> dict[str, Any]:
diagnostics = dict(getattr(exc, "diagnostics", None) or {})
report = public_cost_report(diagnostics)
if report and diagnostics.get("cost_report") is None:
diagnostics["cost_report"] = report
detail: dict[str, Any] = {
"code": getattr(exc, "code", None) or "error",
"message": getattr(exc, "message", None) or str(exc),
"cost_report": report,
}
http_status = diagnostics.get("http_status")
if http_status is not None:
detail["http_status"] = http_status
provider_message = diagnostics.get("provider_message")
if provider_message:
detail["provider_message"] = provider_message
if diagnostics:
detail["diagnostics"] = diagnostics
return detail
_LOG_FORBIDDEN_KEYS = frozenset(
{
"local_label",
@ -227,6 +386,7 @@ def _raise_with_log(
diagnostics["purpose"] = purpose
if extra:
diagnostics.update({key: value for key, value in extra.items() if value is not None})
diagnostics["cost_report"] = public_cost_report(diagnostics)
raise PrivacyGatewayError(exc.code, exc.message, exc.status_code, diagnostics) from exc
@ -835,6 +995,14 @@ def _fake_complete(purpose: str, rendered: str) -> str:
return '{"operation":"erleben_vertiefen","impulse":"' + impulse + '"}'
def _generate_timeout(purpose: str) -> float:
if purpose in JOURNAL_PURPOSES:
return 90
if purpose == "profile_review":
return 120
return 60
def complete_model(messages: list[dict], policy: dict[str, Any]) -> ChatResult:
config = generate_provider()
if not config:
@ -851,7 +1019,7 @@ def complete_model(messages: list[dict], policy: dict[str, Any]) -> ChatResult:
return complete_chat(
config,
messages,
timeout=90 if (policy.get("purpose") in JOURNAL_PURPOSES) else 60,
timeout=_generate_timeout(policy.get("purpose") or ""),
max_tokens=policy.get("max_tokens"),
disable_context_compression=bool(policy.get("disable_context_compression")),
)
@ -911,7 +1079,13 @@ def complete(request: GatewayRequest) -> GatewayResult:
exc.code,
user_detect_message(exc.code, exc.message),
exc.status_code,
{"log": events, "purpose": request.purpose, "generate_called": False, **detect_stats},
{
"log": events,
"purpose": request.purpose,
"generate_called": False,
"generate_requested": False,
**detect_stats,
},
) from exc
_log_event(
events,
@ -945,6 +1119,7 @@ def complete(request: GatewayRequest) -> GatewayResult:
diagnostics.update(summary)
diagnostics.update({key: value for key, value in detect_stats.items() if value is not None})
diagnostics["generate_called"] = False
diagnostics["generate_requested"] = False
if request.purpose in JOURNAL_PURPOSES:
if budget is None:
_log_event(events, started, "budget", status="error", code="model_metadata_unknown")
@ -1079,6 +1254,8 @@ def complete(request: GatewayRequest) -> GatewayResult:
purpose=request.purpose,
model=config.model if config else None,
)
diagnostics["generate_requested"] = True
request_trace["generate_requested"] = True
chat = complete_model([{"role": "user", "content": masked}], model_policy)
usages.append(chat.usage or {})
diagnostics["generate_called"] = True
@ -1198,6 +1375,7 @@ def public_trace(trace: dict | None) -> dict | None:
"detect_ms": trace.get("detect_ms"),
"generate_ms": (trace.get("budget") or {}).get("generate_ms") if isinstance(trace.get("budget"), dict) else trace.get("generate_ms"),
"generate_called": trace.get("generate_called"),
"generate_requested": trace.get("generate_requested"),
"mapping_count": trace.get("mapping_count"),
"available_mapping_count": trace.get("available_mapping_count"),
"active_mapping_count": trace.get("active_mapping_count"),

View File

@ -911,7 +911,24 @@ def run_api_review(profile_id: str, review_id: str | None = None, *, mode: str |
subject_id=review_id or "",
)
raise
parsed = parse_result(result.get("content") or "")
try:
parsed = parse_result(result.get("content") or "")
except StoreError as exc:
diag = dict(result.get("diagnostics") or {})
if result.get("trace") is not None:
diag.setdefault("trace", result.get("trace"))
diag["generate_called"] = True if diag.get("generate_called") is None else diag.get("generate_called")
diag["generate_requested"] = True
diag["abort_reason"] = exc.code
wrapped = EngineError(exc.code, exc.message, getattr(exc, "status_code", 400), diag)
persist_engine_error(
profile_id,
purpose="profile_review",
exc=wrapped,
subject_type="writing_profile_review",
subject_id=review_id or "",
)
raise wrapped from exc
if not parsed.get("mode"):
parsed["mode"] = package.get("mode") or ""
staged = _stage_proposal(profile_id, parsed, review_id=review_id, package=package)

View File

@ -1,6 +1,7 @@
"""Two independently configured OpenAI-compatible providers: generate vs detect."""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass, field
@ -28,6 +29,53 @@ class ProviderError(Exception):
self.diagnostics = diagnostics or {}
PROVIDER_MESSAGE_MAX = 240
_PROVIDER_USAGE_KEYS = ("prompt_tokens", "completion_tokens", "total_tokens", "cost", "total_cost")
def provider_error_diagnostics(status_code: int, body: str, *, extra: dict | None = None) -> dict:
"""Compact provider failure fields. No prompt body, no mapping labels."""
diagnostics: dict = {"http_status": status_code}
if extra:
diagnostics.update({key: value for key, value in extra.items() if value is not None})
parsed = None
try:
parsed = json.loads(body or "")
except (TypeError, ValueError, json.JSONDecodeError):
parsed = None
if isinstance(parsed, dict):
err = parsed.get("error")
if isinstance(err, str) and err.strip():
diagnostics["provider_message"] = err.strip()[:PROVIDER_MESSAGE_MAX]
elif isinstance(err, dict):
msg = str(err.get("message") or err.get("msg") or "").strip()
if msg:
diagnostics["provider_message"] = msg[:PROVIDER_MESSAGE_MAX]
code = err.get("code") or err.get("type")
if code:
diagnostics["provider_code"] = str(code)[:80]
usage = parsed.get("usage")
if isinstance(usage, dict):
kept = {key: usage[key] for key in _PROVIDER_USAGE_KEYS if usage.get(key) is not None}
if kept:
diagnostics["usage"] = kept
if kept.get("cost") is not None:
diagnostics["cost"] = kept["cost"]
elif kept.get("total_cost") is not None:
diagnostics["cost"] = kept["total_cost"]
return diagnostics
def _reject_message(role: str, status_code: int, diagnostics: dict) -> str:
base = f"{role}-Provider hat die Anfrage abgelehnt"
if status_code:
base += f" (HTTP {status_code})"
snippet = (diagnostics.get("provider_message") or "").strip()
if snippet:
return f"{base}: {snippet}"
return f"{base}."
@dataclass(frozen=True)
class ProviderConfig:
role: str
@ -193,23 +241,45 @@ def complete_chat(
headers["X-Title"] = "Kansho"
try:
response = httpx.post(config.url, json=payload, headers=headers, timeout=timeout)
except httpx.TimeoutException as exc:
raise ProviderError(
"provider_timeout",
f"{config.role}-Provider hat nicht innerhalb von {int(timeout)} Sekunden geantwortet.",
504,
diagnostics={"timeout_s": timeout, "generate_requested": True},
) from exc
except httpx.HTTPError as exc:
raise ProviderError("provider_unreachable", f"{config.role}-Provider ist nicht erreichbar.") from exc
raise ProviderError(
"provider_unreachable",
f"{config.role}-Provider ist nicht erreichbar.",
503,
diagnostics={"generate_requested": True},
) from exc
if response.status_code >= 400:
body = ""
try:
body = response.text or ""
except Exception:
body = ""
diagnostics = provider_error_diagnostics(
response.status_code,
body,
extra={"context_compression": compression, "generate_requested": True},
)
if CONTEXT_LENGTH_REJECT.search(body):
raise ProviderError(
"provider_context_length_rejected",
"Der Anbieter hat die Anfrage wegen der Kontextlänge abgelehnt. "
"Kanshō hat den Tagesdialog nicht automatisch gekürzt.",
502,
diagnostics={"http_status": response.status_code, "context_compression": compression},
diagnostics=diagnostics,
)
raise ProviderError("provider_rejected", f"{config.role}-Provider hat die Anfrage abgelehnt.")
raise ProviderError(
"provider_rejected",
_reject_message(config.role, response.status_code, diagnostics),
502,
diagnostics=diagnostics,
)
data = response.json()
try:
content = data["choices"][0]["message"]["content"]

View File

@ -190,8 +190,9 @@ def _space_recent_sources(profile_id: str, spec: dict[str, Any]) -> list[dict]:
with get_db() as conn:
conversations = conn.execute(
f"""
SELECT c.id, c.updated, c.journal_day_id
SELECT c.id, c.updated, c.journal_day_id, d.calendar_date
FROM conversations c
LEFT JOIN journal_days d ON d.id = c.journal_day_id AND d.profile_id = c.profile_id
WHERE c.profile_id = ? AND c.space_id = ? {extra}
ORDER BY c.updated DESC, c.created DESC
LIMIT ?
@ -217,6 +218,7 @@ def _space_recent_sources(profile_id: str, spec: dict[str, Any]) -> list[dict]:
{
"conversation_id": conv["id"],
"journal_day_id": conv["journal_day_id"],
"calendar_date": conv["calendar_date"],
"excerpt": excerpt,
}
)

View File

@ -9,6 +9,7 @@ from continuity import checkpoint_usage_session, close_usage_session
from derived_kinds import catalog
from dialogue_turn import run_turn, visible_for_role
from engine import EngineError
from privacy_gateway import public_error_detail
from dialogue_store import (
StoreError,
append_message,
@ -31,11 +32,7 @@ router = APIRouter(prefix="/api/dialogue", tags=["dialogue"])
def _http(exc: StoreError | EngineError):
detail = {"code": exc.code, "message": exc.message}
extra = getattr(exc, "diagnostics", None)
if extra:
detail["diagnostics"] = extra
raise HTTPException(status_code=exc.status_code, detail=detail) from exc
raise HTTPException(status_code=exc.status_code, detail=public_error_detail(exc)) from exc
class SessionWrite(BaseModel):

View File

@ -31,6 +31,7 @@ class GuidelineWrite(BaseModel):
label: str | None = None
summary: str | None = None
instruction: str | None = None
style_context: dict | None = None
sort_order: int | None = None

View File

@ -7,7 +7,7 @@ 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 privacy_gateway import GatewayRequest, inspect, public_error_detail
from engine import EngineError
from journal_generate import generate_draft
from journal_generation_policy import settings_payload
@ -76,11 +76,7 @@ router = APIRouter(prefix="/api/journal", tags=["journal"])
def _http(exc: StoreError | PolicyError | EngineError):
detail = {"code": exc.code, "message": exc.message}
extra = getattr(exc, "diagnostics", None)
if extra:
detail["diagnostics"] = extra
raise HTTPException(status_code=exc.status_code, detail=detail) from exc
raise HTTPException(status_code=exc.status_code, detail=public_error_detail(exc)) from exc
class SpaceWrite(BaseModel):

View File

@ -482,6 +482,7 @@ CREATE TABLE IF NOT EXISTS generation_guidelines (
label TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
instruction TEXT NOT NULL,
style_context_json TEXT NOT NULL DEFAULT '{}',
sort_order INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('draft', 'active', 'archived')),
revision INTEGER NOT NULL DEFAULT 1,

View File

@ -0,0 +1,505 @@
"""Bounded detect-contract retry, acyclic diagnostics, persist must not hide EngineError.
Run from backend/: python tests/test_detect_contract_retry.py
Fake detect/provider only. No live calls.
"""
from __future__ import annotations
import json
import os
import sys
import tempfile
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
os.environ["KANSHO_DB_PATH"] = str(Path(tempfile.gettempdir()) / "kansho-detect-contract-retry-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 debug_store import persist_engine_error, sanitize
from engine import EngineError
from entity_detect import (
DetectError,
detect_personal_egress,
install_test_detect_failure,
install_test_detect_script,
reset_detect_test_hooks,
split_detect_chunks,
validate_detected_entity,
)
from journal_generate import _compose_journal_trace, _stage_trace
from main import app
from privacy_gateway import GatewayRequest, PrivacyGatewayError, complete, reset_debug
from providers import ChatResult
USAGE_A = {"prompt_tokens": 10, "completion_tokens": 3, "total_tokens": 13, "cost": 0.001}
USAGE_B = {"prompt_tokens": 8, "completion_tokens": 2, "total_tokens": 10, "cost": 0.0005}
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 _ids(obj, stack=None) -> bool:
"""True if obj is acyclic."""
if not isinstance(obj, (dict, list, tuple)):
return True
stack = stack or set()
ident = id(obj)
if ident in stack:
return False
stack.add(ident)
try:
items = obj.values() if isinstance(obj, dict) else obj
return all(_ids(item, stack) for item in items)
finally:
stack.discard(ident)
def _budget_is_not_diagnostics(payload: dict) -> bool:
diag = payload.get("diagnostics") if isinstance(payload.get("diagnostics"), dict) else payload
trace = diag.get("trace") if isinstance(diag.get("trace"), dict) else {}
budget = trace.get("budget")
if budget is diag:
return False
for stage in trace.get("stages") or []:
if isinstance(stage, dict) and stage.get("budget") is diag:
return False
return True
def _run(profile_id: str, rendered: str, purpose: str = "dialogue_turn"):
return complete(
GatewayRequest(
prompt_id="detect-retry-test",
purpose=purpose,
data_class="B",
profile_id=profile_id,
payload={"rendered": rendered, "source_text": rendered},
)
)
def _chat(entities: list[dict], usage: dict) -> ChatResult:
return ChatResult(
content=json.dumps({"entities": entities}),
model="fake-detect",
usage=usage,
finish_reason="stop",
)
def _invalid_type():
return [{"start": 0, "end": 1, "text": "x", "entity_type": "FOOD"}]
def _extra_field():
return [{"start": 0, "end": 1, "text": "x", "entity_type": "PERSON", "score": 0.9}]
def _missing_field():
return [{"start": 0, "text": "x", "entity_type": "PERSON"}]
def _bad_offset(chunk_text: str):
return [{"start": 0, "end": 5, "text": "ZZZXQ", "entity_type": "PERSON"}]
def open_space_day(client, headers, title="Alltag", date="2026-08-29"):
space = client.post("/api/journal/spaces", headers=headers, json={"title": title})
expect(space.status_code == 200, f"create space {space.status_code}")
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.status_code}")
return space.json(), day.json()
def main() -> None:
reset_debug()
reset_detect_test_hooks()
sample = "Ich traf Anna."
try:
validate_detected_entity(
{"start": 0, "end": 4, "text": "Anna", "entity_type": "FOOD"},
sample,
0,
)
typed = False
except DetectError as exc:
typed = exc.code == "detect_invalid_output"
expect((exc.diagnostics or {}).get("invalid_entity_type") == "FOOD", "unknown type is recorded without the span text")
expect((exc.diagnostics or {}).get("contract_violation") == "unknown_entity_type", "unknown type has a violation category")
expect("Anna" not in json.dumps(exc.diagnostics or {}), "span plaintext is not stored on the type violation")
expect(typed, "unknown entity_type is a contract error")
cyclic = {}
nested = {"budget": cyclic, "stages": [cyclic]}
cyclic["trace"] = nested
cyclic["local_label"] = "secret-name"
cleaned = sanitize(cyclic)
expect(cleaned is not None, "cyclic sanitize returns a value")
expect(_ids(cleaned), "sanitized cyclic input is acyclic")
expect("local_label" not in json.dumps(cleaned), "sanitize still drops mapping labels")
expect(json.dumps(cleaned, default=str), "sanitized cyclic input is JSON-serializable")
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")
token = setup.json()["token"]
headers = header(token)
profile_id = setup.json()["profile_id"]
calls = []
def retry_then_ok(attempt, chunk_index, chunk_text, offset, schema_retry=False):
usage = USAGE_A if attempt == 1 else USAGE_B
calls.append((attempt, chunk_index, schema_retry))
if attempt == 1:
return _chat(_invalid_type(), usage)
return _chat([], usage)
long_body = (
"WRITING_PROFILE\nkurze trockene Saetze. " * 12
+ "STYLE_EXAMPLES\nDer Abend blieb ruhig. " * 12
+ "CURRENT_DAY_SOURCES\nHeute der Markt, danach der Hafen. "
)
with patch("entity_detect.DETECT_CHUNK_CHARS", 90), patch("entity_detect.DETECT_CHUNK_OVERLAP", 12):
chunks = split_detect_chunks(long_body, chunk_chars=90, overlap=12)
expect(len(chunks) >= 2, "long profile+examples text is chunked")
install_test_detect_script(retry_then_ok)
try:
outcome = detect_personal_egress(profile_id, long_body)
finally:
reset_detect_test_hooks()
attempts = {item[0] for item in calls}
expect(attempts == {1, 2}, "invalid first pass starts a second full pass")
first_chunks = [item[1] for item in calls if item[0] == 1]
second_chunks = [item[1] for item in calls if item[0] == 2]
expect(second_chunks == list(range(len(chunks))), "second pass re-checks every chunk")
expect(first_chunks == [0], "failed first pass stops at the invalid chunk and discards partials")
expect(any(item[2] for item in calls if item[0] == 2), "second pass is marked as schema retry")
expect(not any(item[2] for item in calls if item[0] == 1), "first pass has no schema retry hint")
expect(outcome.stats.detect_partial_discarded is True, "partial first-pass spans are discarded")
expect(outcome.stats.detect_passes == 2, "successful retry records two passes")
expect(outcome.stats.detect_calls == len(calls), "detect calls sum both passes")
expect(outcome.stats.cost_known is True, "injected usage keeps known detect cost")
expected_cost = USAGE_A["cost"] * len(first_chunks) + USAGE_B["cost"] * len(second_chunks)
expect(abs(outcome.stats.cost - expected_cost) < 1e-9, "detect costs of both passes are aggregated")
expect(
abs(
outcome.stats.prompt_tokens
- (USAGE_A["prompt_tokens"] * len(first_chunks) + USAGE_B["prompt_tokens"] * len(second_chunks))
)
< 1e-9,
"detect tokens of both passes are aggregated",
)
expect(outcome.stats.full_detection_coverage is True, "valid second pass has full coverage")
expect(outcome.stats.detect_ms >= 0, "retry records duration")
generate_n = {"n": 0}
def count_generate(messages, policy):
generate_n["n"] += 1
from privacy_gateway import _fake_complete
return ChatResult(content=_fake_complete(policy.get("purpose") or "", messages[0].get("content") or ""), model="fake")
install_test_detect_script(retry_then_ok)
generate_n["n"] = 0
with patch("privacy_gateway.complete_model", count_generate):
try:
ran = _run(profile_id, "Heute der Markt und danach der Hafen.")
finally:
reset_detect_test_hooks()
expect(ran.trace.get("generate_called") is True, "valid retry allows exactly one narration call")
expect(generate_n["n"] == 1, "narration runs once after a recovered detect pass")
expect(_ids(ran.diagnostics or {}), "successful diagnostics are acyclic")
expect(_ids(ran.trace or {}), "successful trace is acyclic")
def always_invalid(attempt, chunk_index, chunk_text, offset, schema_retry=False):
return _chat(_invalid_type(), USAGE_A)
generate_n["n"] = 0
install_test_detect_script(always_invalid)
blocked = None
with patch("privacy_gateway.complete_model", count_generate):
try:
_run(profile_id, "Heute der Markt und danach der Hafen.")
except PrivacyGatewayError as exc:
blocked = exc
finally:
reset_detect_test_hooks()
expect(blocked is not None and blocked.code == "detect_invalid_output", "two invalid passes fail closed")
expect(generate_n["n"] == 0, "invalid detect never calls narration")
expect(blocked.diagnostics.get("generate_called") is False, "final abort keeps generate_called false")
expect(blocked.diagnostics.get("detect_passes") == 2, "both invalid passes are counted")
expect(blocked.diagnostics.get("contract_violation") == "unknown_entity_type", "final abort keeps the violation category")
expect(blocked.diagnostics.get("invalid_entity_type") == "FOOD", "delivered type is stored without span text")
expect(blocked.diagnostics.get("detect_partial_discarded") is True, "failed retry discarded the first pass")
expect(_ids(blocked.diagnostics), "failed diagnostics are acyclic")
expect("FOOD" in json.dumps(blocked.diagnostics), "invalid type remains visible")
expect("secret" not in json.dumps(blocked.diagnostics).lower(), "no extra identity payload")
expect(blocked.status_code != 500, "detect abort is not a generic 500")
def extra_then_fail(attempt, chunk_index, chunk_text, offset, schema_retry=False):
return _chat(_extra_field(), USAGE_A)
install_test_detect_script(extra_then_fail)
extra_err = None
try:
detect_personal_egress(profile_id, "Heute der Markt.")
except DetectError as exc:
extra_err = exc
finally:
reset_detect_test_hooks()
expect(extra_err is not None and extra_err.diagnostics.get("contract_violation") == "extra_fields", "extra fields fail closed after retry")
def missing_then_fail(attempt, chunk_index, chunk_text, offset, schema_retry=False):
return _chat(_missing_field(), USAGE_A)
install_test_detect_script(missing_then_fail)
missing_err = None
try:
detect_personal_egress(profile_id, "Heute der Markt.")
except DetectError as exc:
missing_err = exc
finally:
reset_detect_test_hooks()
expect(missing_err is not None and missing_err.diagnostics.get("contract_violation") == "missing_fields", "missing fields fail closed after retry")
def offset_then_fail(attempt, chunk_index, chunk_text, offset, schema_retry=False):
return _chat(_bad_offset(chunk_text), USAGE_A)
install_test_detect_script(offset_then_fail)
offset_err = None
try:
detect_personal_egress(profile_id, "Heute der Markt.")
except DetectError as exc:
offset_err = exc
finally:
reset_detect_test_hooks()
expect(offset_err is not None and offset_err.diagnostics.get("contract_violation") == "unusable_offsets", "unusable offsets fail closed after retry")
net_calls = []
def network_fail(attempt, chunk_index, chunk_text, offset, schema_retry=False):
net_calls.append(attempt)
raise DetectError("detect_chunk_failed", "chunk failed")
install_test_detect_script(network_fail)
net_err = None
try:
detect_personal_egress(profile_id, "Heute der Markt.")
except DetectError as exc:
net_err = exc
finally:
reset_detect_test_hooks()
expect(net_err is not None and net_err.code == "detect_chunk_failed", "provider/chunk failure stays distinct")
expect(net_calls == [1], "network-like chunk failure is not doubled by the contract retry")
install_test_detect_failure(DetectError("detect_chunk_failed", "chunk failed"))
immediate = None
try:
detect_personal_egress(profile_id, "Heute der Markt.")
except DetectError as exc:
immediate = exc
finally:
reset_detect_test_hooks()
expect(immediate is not None and immediate.diagnostics.get("detect_passes") == 0, "injected provider failure does not start a contract retry")
mixed = []
def worker(kind: str):
reset_debug()
seen = []
def script(attempt, chunk_index, chunk_text, offset, schema_retry=False):
seen.append((kind, attempt))
if attempt == 1:
entity_type = "FOOD" if kind == "alpha" else "ANIMAL"
return _chat([{"start": 0, "end": 1, "text": "x", "entity_type": entity_type}], USAGE_A)
return _chat([], USAGE_B)
install_test_detect_script(script)
try:
out = detect_personal_egress(profile_id, f"Heute der Markt {kind}.")
mixed.append((kind, tuple(seen), out.stats.invalid_entity_type, out.stats.detect_calls, id(out.stats)))
finally:
reset_detect_test_hooks()
with ThreadPoolExecutor(max_workers=2) as pool:
one = pool.submit(worker, "alpha")
two = pool.submit(worker, "beta")
one.result()
two.result()
kinds = {item[0] for item in mixed}
expect(kinds == {"alpha", "beta"}, "parallel detect retries both complete")
by_kind = {item[0]: item for item in mixed}
expect(
all(kind == row[0] for row in by_kind.values() for kind, _attempt in row[1]),
"parallel scripts stay on their request",
)
expect(by_kind["alpha"][2] in (None, "FOOD"), "alpha does not keep beta's type")
expect(by_kind["beta"][2] in (None, "ANIMAL"), "beta does not keep alpha's type")
expect(by_kind["alpha"][4] != by_kind["beta"][4], "parallel stats objects are distinct")
failed = {
"diagnostics": {"generate_called": False, "detect_cost": 0.001, "cost": None, "purpose": "journal_generate"},
"trace": {"purpose": "journal_generate", "generate_called": False},
"content": "",
}
staged = _stage_trace(failed, "journal_generate")
expect(staged.get("budget") is not failed["diagnostics"], "stage budget is a snapshot, not the live diagnostics object")
failed["diagnostics"]["trace"] = {"budget": staged.get("budget")}
expect(_ids(failed), "attaching the snapshot does not create a diagnostics cycle")
composed = _compose_journal_trace(
{"trace": {"purpose": "local_source_artifact"}, "diagnostics": {"prompt_tokens": 0}},
failed,
run_log=[{"kind": "detect", "status": "error"}],
narrate_prompt={"slug": "mvp.journal_generate", "seed_revision": "test"},
profile_meta={"present": False},
style_meta={"count": 0},
dropped=[],
extra={"generation_selection": {"voice_id": "journal-generate-voice-clear"}, "style_application": {"include_core": False}},
)
failed["diagnostics"]["trace"] = composed
expect(_ids(failed["diagnostics"]), "composed journal diagnostics stay acyclic")
expect(_budget_is_not_diagnostics(failed), "composed budget is not a back-reference")
expect(json.dumps(sanitize(failed), default=str), "composed failure is serializable")
space, day = open_space_day(client, headers)
day_id = day["day"]["id"]
conv = client.post(
f"/api/journal/days/{day_id}/conversations",
headers=headers,
json={"title": "Gespräch"},
)
turn = client.post(
f"/api/journal/conversations/{conv.json()['id']}/turn",
headers=headers,
json={"body": "Heute war der Markt voll und danach der Hafen ruhig."},
)
expect(turn.status_code == 200, f"turn {turn.status_code}")
enabled = client.put("/api/admin/debug", headers=headers, json={"persist_enabled": True})
expect(enabled.status_code == 200 and enabled.json()["persist_enabled"] is True, "persist enabled")
before = client.get("/api/admin/debug/runs", headers=headers).json()["runs"]
install_test_detect_script(always_invalid)
try:
gen = client.post(
f"/api/journal/days/{day_id}/generate",
headers=headers,
json={"conversation_ids": [conv.json()["id"]]},
)
finally:
reset_detect_test_hooks()
expect(gen.status_code == 503, f"journal detect abort status {gen.status_code}")
detail = gen.json()["detail"]
expect(detail["code"] == "detect_invalid_output", "API keeps the detect contract code")
expect(detail["code"] != "error" and gen.status_code != 500, "detect abort is not HTTP 500")
expect("Angaben" in (detail.get("message") or ""), "API uses the user-facing detect message")
expect(detail.get("cost_report"), "API includes a cost report")
expect(detail["cost_report"].get("generate_called") is False, "cost report says generate was not called")
expect(
(detail.get("diagnostics") or {}).get("generate_called") is False,
"diagnostics keep generate_called false",
)
expect(_ids(detail), "API error payload is acyclic")
expect(_budget_is_not_diagnostics(detail), "API error budget is not a diagnostics alias")
after = client.get("/api/admin/debug/runs", headers=headers).json()["runs"]
error_runs = [
item
for item in after
if item.get("purpose") == "journal_generate" and item.get("status") == "error"
]
expect(len(error_runs) == 1, "failed journal request is stored exactly once")
run = error_runs[0]
expect(run.get("journal_day_id") == day_id, "error run is attached to the journal day")
detail_run = client.get(f"/api/admin/debug/runs/{run['id']}", headers=headers)
expect(detail_run.status_code == 200, f"error run detail {detail_run.status_code}")
payload = detail_run.json().get("payload") or {}
expect(payload.get("code") == "detect_invalid_output", "stored run keeps the detect code")
expect(payload.get("generate_called") is False, "stored run records generate_called false")
expect(payload.get("detect_passes") == 2, "stored run records both detect passes")
expect(payload.get("detect_partial_discarded") is True, "stored run records discarded partials")
expect(payload.get("contract_violation") == "unknown_entity_type", "stored run records the violation")
expect(payload.get("invalid_entity_type") == "FOOD", "stored run records the delivered type")
expect(payload.get("generation_selection") or (payload.get("trace") or {}).get("generation_selection"), "stored run keeps generation selection")
expect("local_label" not in json.dumps(payload), "stored run has no mapping labels")
exported = client.get(
f"/api/admin/debug/export?format=json&journal_day_id={day_id}&purpose=journal_generate",
headers=headers,
)
expect(exported.status_code == 200, f"error run export {exported.status_code}")
document = exported.json()
encoded = json.dumps(document)
expect(encoded, "exported error run is JSON-serializable")
expect("detect_invalid_output" in encoded, "export contains the detect code")
hidden = EngineError("detect_invalid_output", "Persönliche Angaben konnten nicht sicher zugeordnet werden.", 503)
with patch("debug_store.persist_step", side_effect=RuntimeError("persist boom")):
persist_engine_error(profile_id, purpose="journal_generate", exc=hidden, journal_day_id=day_id)
install_test_detect_script(always_invalid)
try:
with patch("debug_store.persist_step", side_effect=RuntimeError("persist boom")):
gen_hidden = client.post(
f"/api/journal/days/{day_id}/generate",
headers=headers,
json={"conversation_ids": [conv.json()["id"]]},
)
finally:
reset_detect_test_hooks()
expect(gen_hidden.status_code == 503, f"persist failure keeps detect status {gen_hidden.status_code}")
expect(gen_hidden.json()["detail"]["code"] == "detect_invalid_output", "persist failure does not replace EngineError")
expect(gen_hidden.status_code != 500, "persist failure is not HTTP 500")
reset_detect_test_hooks()
control_space, control_day = open_space_day(client, headers, "Kontrolle", "2026-08-28")
control_conv = client.post(
f"/api/journal/days/{control_day['day']['id']}/conversations",
headers=headers,
json={"title": "Gespräch"},
)
control_turn = client.post(
f"/api/journal/conversations/{control_conv.json()['id']}/turn",
headers=headers,
json={"body": "Heute Tee auf dem Balkon, danach nur der Wind."},
)
expect(control_turn.status_code == 200, "control turn")
control = client.post(
f"/api/journal/days/{control_day['day']['id']}/generate",
headers=headers,
json={"conversation_ids": [control_conv.json()["id"]]},
)
expect(control.status_code == 200, f"control generate without profile {control.status_code}")
expect((control.json().get("trace") or {}).get("generate_called") is True, "control run still narrates once")
expect(_ids(control.json().get("trace") or {}), "control trace is acyclic")
client.put("/api/admin/debug", headers=headers, json={"persist_enabled": False})
print("ALL TESTS PASSED")
if __name__ == "__main__":
main()

View File

@ -873,7 +873,7 @@ def test_generate_flow(client: TestClient, headers: dict) -> None:
expect("Vielleicht bleibe ich kürzer" in narrate, "omitted labels do not drop canonical hedges")
expect("assistant:" not in (gen.json().get("body") or ""), "draft body has no assistant lines")
expect("WRITING_PROFILE" in narrate or "Neutraler Journalstil" in narrate or "Core:" in narrate, "writing profile reaches generate")
expect("STYLE_EXAMPLES" in narrate, "style examples are labeled separately from day facts")
expect("STYLE_EXAMPLES" not in narrate, "default voice does not label a style-examples block")
expect("Erzählmerkmale" not in (narrate.split("\nCURRENT_DAY_SOURCES\n")[0] if "\nCURRENT_DAY_SOURCES\n" in narrate else narrate), "day dialogue is not a style brief")
reset_debug()

View File

@ -71,7 +71,6 @@ def test_prompt_contract() -> None:
text = prompt.get("template") or ""
expect("INHALTSTREUE" in text, "prompt keeps hard content rules")
expect("CURRENT_DAY_SOURCES" in text, "prompt labels current-day facts")
expect("STYLE_EXAMPLES" in text, "prompt labels style examples")
expect("WRITING_PROFILE" in text, "prompt labels the writing profile")
expect("{{transformation_instructions}}" in text, "prompt injects compiled transformation instructions")
expect("{{detail_instructions}}" in text, "prompt injects compiled detail instructions")
@ -253,8 +252,9 @@ def main() -> None:
intern = intern_of(gen.json())
expect(MIXED_SOURCES_INSTRUCTION in intern, "unified mixed-source instruction reaches the model")
expect("CURRENT_DAY_SOURCES" in intern, "day facts are labeled")
expect("STYLE_EXAMPLES" in intern, "style examples are labeled")
expect("WRITING_PROFILE" in intern, "writing profile is labeled")
expect("\nSTYLE_EXAMPLES\n" not in intern, "default voice does not attach a style-example section")
expect("STYLE_EXAMPLES" not in intern, "default voice has no orphaned STYLE_EXAMPLES reference")
expect("WRITING_PROFILE" in intern, "writing profile heading or instruction remains")
expect("Überarbeite den Text substanziell" in intern, "default transformation policy reaches the prompt")
expect("Erhalte sämtliche belegten Ereignisse" in intern, "default detail policy reaches the prompt")
expect("Neutraler Journalstil" in intern, "without a confirmed profile the neutral fallback is used")

View File

@ -49,7 +49,7 @@ def main() -> None:
expect("{{source_mode_instructions}}" not in templates[VARIANT_CURRENT], "current variant has no source-mode placeholder")
expect("Zwinge nicht den gesamten Tag in einen einzigen Quellenmodus." in templates[VARIANT_CURRENT], "current variant has mixed-source instruction")
expect("ich gieng zum laden" not in templates[VARIANT_CURRENT], "current variant has no synthetic examples")
expect(GENERATE_SEED_REVISION == "2026-08-27-journal-mixed-sources-v1", "eval tracks the seeded revision constant")
expect(GENERATE_SEED_REVISION == "2026-08-29-voice-legacy-immutable-v1", "eval tracks the seeded revision constant")
required_classes = {
"already_narrative_with_errors",

View File

@ -60,7 +60,6 @@ HARD_FACTS = (
"Plan und Vollzug",
"Verneinungen",
"Unsicherheiten",
"STYLE_EXAMPLES dienen ausschließlich als Stilreferenz",
"[[PERSON:01]]",
)
BANNED_CODE_PHRASES = (
@ -101,6 +100,11 @@ REQUIRED_KEYS = {
("voice", "light"),
("voice", "noticeable"),
("voice", "clear"),
("voice", "with_examples"),
("voice", "legacy_neutral"),
("voice", "legacy_light"),
("voice", "legacy_noticeable"),
("voice", "legacy_clear"),
("narrative", "chronicle"),
("narrative", "structured"),
("narrative", "weighted"),
@ -209,7 +213,9 @@ def test_runtime_has_no_numeric_policy() -> None:
continue
expect(not has_token(text, token), f"{path.name} has no {token}")
day = (FRONTEND / "src" / "pages" / "JournalDayPage.jsx").read_text(encoding="utf-8")
expect('type="range"' not in day, "journal page has no sliders")
expect("Stilquellen" in day, "voice select can show which style sources apply")
expect("type=\"range\"" not in day, "journal page has no extra sliders for style context")
expect(day.count("<select") == 1, "journal page maps four slots onto one select, not extra style-context fields")
expect("policy-slider" not in day, "journal page has no slider class")
expect("SLOT_SELECTS" in day, "journal page names the four selects")
expect(day.count("slot:") >= 4, "journal page has four independent slots")
@ -224,7 +230,10 @@ def test_runtime_has_no_numeric_policy() -> None:
admin = (FRONTEND / "src" / "pages" / "AdminGenerationPage.jsx").read_text(encoding="utf-8")
expect("<textarea" not in admin.split("generation-admin-editor")[0], "admin overview has no textareas")
expect(admin.count("<textarea") == 1, "admin opens one instruction editor at a time")
expect("Öffnen" in admin, "admin overview has open action")
expect("Stilquellen für diese Ausprägung" in admin, "admin edits style context with the instruction")
expect("Vorgänger" in admin, "admin shows predecessor or clone origin")
expect("include_core" in admin, "admin names core inclusion")
expect("Writing-Profile-Core" in admin, "admin labels core in German product terms")
expect("backend/config/" not in admin, "admin copy does not advertise seed file paths")
expect("Quellenmodus" not in admin, "admin has no source-mode management")
expect("source_mode" not in admin, "admin source has no source_mode slot")
@ -239,6 +248,8 @@ def test_runtime_has_no_numeric_policy() -> None:
expect("Chunks technisch verarbeitet" in trace_ui, "trace separates technical chunk coverage")
expect("Semantische Identitätserkennung" in trace_ui, "trace names semantic uncertainty")
expect("nicht garantiert" in trace_ui, "trace does not claim semantic completeness")
expect("Stilanwendung" in trace_ui, "trace shows requested versus effective style application")
expect("cloned_from" in trace_ui, "trace can show the predecessor id")
expect("Gespeicherter Entwurf" in trace_ui, "trace shows the stored draft")
expect("Technisch verarbeitete Chunks bedeuten keine vollständige" in trace_ui, "trace explains coverage is not identity certainty")
@ -275,8 +286,22 @@ def test_seed_and_compiler() -> None:
"default narrative instruction matches the seed",
)
expect(defaults.seed_revision == SEED["seed_revision"], "compiled policy names the seed revision")
expect(defaults.style_context["include_core"] is True, "default voice includes core")
expect(defaults.style_context["include_facet"] is True, "default voice includes facet")
expect(defaults.style_context["include_traits"] is True, "default voice includes traits")
expect(defaults.style_context["include_style_examples"] is False, "default voice omits style examples")
expect("source_mode_instructions" not in defaults.instructions, "compiler has no source-mode instruction")
free = compile_selection({**default_selection_ids(), "voice_id": seed_id("voice", "neutral")})
expect(free.style_context == {
"include_core": False,
"include_facet": False,
"include_traits": False,
"include_style_examples": False,
}, "profile-free voice selects no style sources")
examples = compile_selection({**default_selection_ids(), "voice_id": seed_id("voice", "with_examples")})
expect(all(examples.style_context.values()), "with-examples voice selects all style sources")
low = compile_selection(low_ids())
high = compile_selection(high_ids())
expect(low.keys["transformation"] == "correction", "low transformation is named")
@ -389,18 +414,19 @@ def test_prompt_contract() -> None:
expect("{{editorial_mode}}" not in text, "prompt has no editorial_mode placeholder")
expect("{{editorial_instructions}}" not in text, "prompt has no editorial_instructions placeholder")
expect(MIXED_SOURCES_INSTRUCTION in text, "prompt contains the mixed-source instruction")
expect("STYLE_EXAMPLES dienen ausschließlich" not in text, "standing prompt has no orphaned STYLE_EXAMPLES sentence")
for fact in HARD_FACTS:
expect(fact in text, f"hard fact rule remains: {fact}")
expect("ich gieng zum laden" not in text, "no synthetic prose example")
expect("nachbarhund im garten" not in text, "no synthetic notes example")
expect("EDITORIAL_MODE" not in text, "source mode is not a user-facing prompt heading")
expect(GENERATE_SEED_REVISION == "2026-08-27-journal-mixed-sources-v1", "seed revision constant tracks mixed sources")
expect(GENERATE_SEED_REVISION == "2026-08-29-voice-legacy-immutable-v1", "seed revision constant tracks immutable voice catalog")
with get_db() as conn:
row = conn.execute(
"SELECT seed_revision, template, default_template FROM ai_prompts WHERE slug = ?",
("mvp.journal_generate",),
).fetchone()
expect(row["seed_revision"] == "2026-08-27-journal-mixed-sources-v1", "untouched default is updated to mixed sources")
expect(row["seed_revision"] == "2026-08-29-voice-legacy-immutable-v1", "untouched default is updated to immutable voice catalog")
expect(row["template"] == row["default_template"], "untouched template matches the seed")
expect("source_mode" not in (SEED.get("slots") or {}), "source modes stay out of the seed")
@ -1019,7 +1045,7 @@ def main() -> None:
).fetchone()
expect("{{transformation_instructions}}" in (row["default_template"] or ""), "default template still tracks mixed sources")
expect(MIXED_SOURCES_INSTRUCTION in (row["default_template"] or ""), "default template has mixed-source instruction")
expect(row["seed_revision"] == "2026-08-27-journal-mixed-sources-v1", "revision updates even when template is custom")
expect(row["seed_revision"] == "2026-08-29-voice-legacy-immutable-v1", "revision updates even when template is custom")
readable = snapshot_summary(
{

View File

@ -51,7 +51,7 @@ def test_active_prompt_contract() -> None:
text = prompt.get("template") or ""
expect("INHALTSTREUE" in text, "active prompt keeps hard content rules")
expect("CURRENT_DAY_SOURCES" in text, "active prompt labels current-day facts")
expect("STYLE_EXAMPLES" in text, "active prompt labels style examples")
expect("{{style_examples}}" in text, "active prompt still has the style-examples slot")
expect("Rechtschreibung" in text or "korrigieren" in text, "active prompt allows spelling and grammar fixes")
expect("Verneinungen" in text and "Unsicherheiten" in text, "active prompt keeps semantic uncertainty")
expect("keine neuen tatsachen" in text.lower(), "active prompt still forbids new facts")
@ -67,7 +67,7 @@ def test_active_prompt_contract() -> None:
"SELECT seed_revision, template, default_template FROM ai_prompts WHERE slug = ?",
("mvp.journal_generate",),
).fetchone()
expect(row["seed_revision"] == "2026-08-27-journal-mixed-sources-v1", "system prompt revision is stored")
expect(row["seed_revision"] == "2026-08-29-voice-legacy-immutable-v1", "system prompt revision is stored")
expect(row["template"] == row["default_template"], "untouched install uses the seeded template")
@ -313,7 +313,7 @@ def main() -> None:
("mvp.journal_generate",),
).fetchone()
expect("INHALTSTREUE" in (row["default_template"] or ""), "default template still tracks the seed")
expect(row["seed_revision"] == "2026-08-27-journal-mixed-sources-v1", "revision updates even when template is custom")
expect(row["seed_revision"] == "2026-08-29-voice-legacy-immutable-v1", "revision updates even when template is custom")
print("journal narration tests passed.")

View File

@ -6,6 +6,8 @@ import sys
import tempfile
from pathlib import Path
from datetime import date
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
@ -23,6 +25,8 @@ from journal_opening import (
attested_plans_from_user_texts,
collect_opening_context,
is_attested_plan,
is_previous_calendar_day,
parse_calendar_date,
)
from journal_reconstruct import assign_source_ids
from retrieval import pair_user_priority_messages
@ -58,6 +62,9 @@ def main() -> None:
expect(not is_attested_plan("Heute war der Hafen ruhig. Kirschen am Stand."), "past report is not a plan")
expect(not is_attested_plan("Wir standen vor dem Hafen und aßen Kirschen."), "vor without haben is not a plan")
expect(not is_attested_plan("Hafen Kirschen Wind"), "word overlap is not a plan")
expect(parse_calendar_date("2026-08-29") == date(2026, 8, 29), "calendar date parses")
expect(is_previous_calendar_day(date(2026, 8, 29), date(2026, 8, 30)), "yesterday is previous")
expect(not is_previous_calendar_day(date(2026, 8, 29), date(2026, 9, 3)), "older than yesterday is not previous")
plans = attested_plans_from_user_texts(
["Heute war der Hafen ruhig.", "Morgen wollen wir nach Cres fahren.", "Hafen Kirschen"]
)
@ -157,6 +164,48 @@ def main() -> None:
expect(overlap_open.json().get("opening", {}).get("kind") == "local_neutral", "overlap falls back to neutral")
expect("Muster" not in (overlap_open.json().get("opening") or {}).get("assistant", {}).get("body", ""), "neutral impulse does not claim a pattern")
done_space, done_day = open_space_day(client, headers, "Abgeschlossen", "2026-08-20")
done_conv = start_conv(client, headers, done_day["day"]["id"])
client.post(
f"/api/journal/conversations/{done_conv.json()['id']}/turn",
headers=headers,
json={"body": "Nachmittag am Strand, abends Eis, dann schlafen."},
)
client.post(
"/api/journal/entries",
headers=headers,
json={
"journal_day_id": done_day["day"]["id"],
"title": "Hitze",
"body": (
"Schon um 6 Uhr schlug mir eine drückende Luft entgegen. "
"Ich hatte überlegt, die Roller auf den nächsten Tag zu schieben. "
"Wir haben sie dann doch geholt und sind den Tag gefahren."
),
"origin": "user_edit",
},
)
later_done = client.post(
f"/api/journal/spaces/{done_space['id']}/days",
headers=headers,
json={"calendar_date": "2026-08-21"},
)
done_open = start_conv(client, headers, later_done.json()["day"]["id"])
done_facts = collect_opening_context(setup.json()["profile_id"], done_open.json()["id"])
expect(not done_facts["attested_plans"], "completed journal entry is not an opening plan")
expect(not done_facts["has_relevant_context"], "a closed earlier day does not keep the next opening relevant")
expect(done_open.json().get("opening", {}).get("kind") == "local_neutral", "completed story falls back to neutral")
stale = client.post(
f"/api/journal/spaces/{plan_space['id']}/days",
headers=headers,
json={"calendar_date": "2026-08-04"},
)
stale_open = start_conv(client, headers, stale.json()["day"]["id"])
stale_facts = collect_opening_context(setup.json()["profile_id"], stale_open.json()["id"])
expect(not any("Cres" in item for item in stale_facts["attested_plans"]), "plan from two days ago is no longer an opening mandate")
expect(stale_open.json().get("opening", {}).get("kind") == "local_neutral", "stale plan uses local neutral")
asst_space, asst_day = open_space_day(client, headers, "Assistent", "2026-08-05")
asst_conv = start_conv(client, headers, asst_day["day"]["id"])
from dialogue_store import append_message

View File

@ -0,0 +1,374 @@
"""Style-application variants control which writing-profile parts reach generate."""
from __future__ import annotations
import os
import sys
import tempfile
from concurrent.futures import ThreadPoolExecutor
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-journal-style-context-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, init_db
from journal_editorial import (
compile_journal_style_application,
style_application_trace,
)
from journal_generation_policy import (
clone_guideline,
compile_selection,
default_selection_ids,
get_guideline,
save_selection,
seed_generation_instructions,
)
from journal_generate import pack_narration_context
from main import app
from privacy_gateway import install_test_recorder, reset_debug
from prompt_budget import JournalBudget
from writing_profile_store import (
compile_task_brief,
import_text,
set_lifecycle,
update_facet,
upsert_trait,
)
CORE_MARK = "ZXCORE_MARKER kurze trockene Saetze"
FACET_MARK = "ZXFACET_MARKER ruhige Abendreflexion"
TRAIT_MARK = "ZXTRAIT_MARKER keine rhythmischen Aufzaehlungen"
EXAMPLE_MARK = "ZXEXAMPLE_MARKER Der Hafen blieb hinter der Faehre"
TODAY_MARK = "ZXTODAY_MARKER am heutigen Markt"
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 intern_of(payload: dict) -> str:
for stage in (payload.get("trace") or {}).get("stages") or []:
if stage.get("purpose") == "journal_generate":
return stage.get("intern") or ""
return (payload.get("trace") or {}).get("intern") or ""
def style_region(intern: str) -> str:
return (intern or "").split("\nCURRENT_DAY_SOURCES\n")[0]
def has_heading(intern: str, title: str) -> bool:
body = intern or ""
return f"\n{title}\n" in body or body.startswith(f"{title}\n")
def seed_id(slot: str, key: str) -> str:
from journal_generation_policy import load_seed_document
seed = load_seed_document()
for item in (seed.get("slots") or {}).get(slot, {}).get("variants") or []:
if item.get("guideline_key") == key:
return item["id"]
raise SystemExit(f"FAIL: missing seed {slot}/{key}")
def selection_of(voice_key: str) -> dict[str, str]:
ids = default_selection_ids()
ids["voice_id"] = seed_id("voice", voice_key)
return ids
def ensure_local_profile(profile_id: str = "style-context-local") -> str:
with get_db() as conn:
conn.execute(
"""
INSERT OR IGNORE INTO profiles (id, email, name, password_hash, role)
VALUES (?, ?, ?, ?, 'user')
""",
(profile_id, f"{profile_id}@example.test", "Style", "x"),
)
return profile_id
def install_profile(profile_id: str) -> None:
update_facet(profile_id, "core", value=CORE_MARK)
update_facet(profile_id, "autobiographical_journal", value=FACET_MARK)
upsert_trait(
profile_id,
slug="journal-rhythm",
facet_key="autobiographical_journal",
label="Rhythmus",
statement=TRAIT_MARK,
origin="manual",
)
set_lifecycle(profile_id, "confirmed")
import_text(
profile_id,
EXAMPLE_MARK + " in knappen ruhigen Saetzen ohne Pathoswolken.",
occurred_at="2026-07-01",
)
def test_compile_flags() -> None:
init_db()
profile_id = ensure_local_profile()
install_profile(profile_id)
full = compile_task_brief(profile_id)
expect(CORE_MARK in full and FACET_MARK in full and TRAIT_MARK in full, "full brief keeps selected parts")
core_only = compile_task_brief(
profile_id,
include_core=True,
include_facet=False,
include_traits=False,
)
expect(CORE_MARK in core_only, "core-only brief keeps core")
expect(FACET_MARK not in core_only, "core-only brief omits facet")
expect(TRAIT_MARK not in core_only, "core-only brief omits traits")
none = compile_task_brief(
profile_id,
include_core=False,
include_facet=False,
include_traits=False,
)
expect(none == "", "no selected profile parts yield an empty brief")
bundle = compile_journal_style_application(profile_id, compile_selection(selection_of("neutral")).style_context)
expect(bundle["profile_block"] == "", "profile-free omits the wrapped profile block")
expect(bundle["examples_block"] == "", "profile-free omits the wrapped examples block")
def test_budget_omission_in_effective_trace() -> None:
init_db()
prompt = {
"id": "style-pack",
"slug": "mvp.journal_generate",
"prompt_type": "base",
"template": "{{writing_profile}}{{style_examples}}DAY\n{{reconstruction}}\nOLD\n{{existing_text}}\n",
}
assembled = {
"writing_profile": "WRITING_PROFILE\nCore: kurze Sätze.\n",
"reconstruction": "Heute Markt.",
"style_examples": "",
"existing_text": "",
}
huge = "Stil " + ("Beispielwort " * 400)
from engine import preview_prompt
from prompt_budget import estimate_tokens
rendered = preview_prompt(prompt, assembled)["rendered"]
budget = JournalBudget(
model="fake",
purpose="journal_generate",
effective_context_window=32_768,
reserved_output_tokens=256,
safety_margin=0.15,
available_input_tokens=estimate_tokens(rendered) + 80,
chars_per_token=2.0,
)
packed, dropped = pack_narration_context(
prompt,
budget,
assembled,
style_examples=huge,
existing_text="Bestehende Fassung",
include_existing=True,
)
expect("style_examples" in dropped, "budget drops style examples first")
expect("Beispielwort" not in (packed.get("style_examples") or ""), "dropped examples leave the packed prompt")
compiled = compile_selection(selection_of("with_examples"))
bundle = {
"requested": dict(compiled.style_context),
"brief": "Core: kurze Sätze.",
"profile_block": assembled["writing_profile"],
"examples_block": huge,
"example_rows": [{"kind": "imported_text", "excerpt": huge}],
"omitted": [],
"effective": {"neutral_fallback": False, "trait_count": 0},
}
trace = style_application_trace(compiled, bundle, dropped=dropped)
expect(trace["effective"]["style_example_count"] == 0, "effective trace has no examples after budget drop")
expect(
any(item.get("part") == "style_examples" and item.get("reason") == "budget" for item in trace["omitted"]),
"budget drop is recorded on the effective trace",
)
expect(trace["id"] == seed_id("voice", "with_examples"), "trace keeps the requested voice id")
expect(trace["revision"] == 1, "trace keeps the requested revision")
def main() -> None:
test_compile_flags()
test_budget_omission_in_effective_trace()
Path(os.environ["KANSHO_DB_PATH"]).unlink(missing_ok=True)
init_db()
reset_debug()
with TestClient(app) as client:
setup = client.post(
"/api/auth/setup",
json={"email": "voice@example.test", "name": "Ada", "password": "test-pass"},
)
headers = header(setup.json()["token"])
profile_id = setup.json()["profile_id"]
install_profile(profile_id)
space = client.post("/api/journal/spaces", headers=headers, json={"title": "Stil"})
day = client.post(
f"/api/journal/spaces/{space.json()['id']}/days",
headers=headers,
json={"calendar_date": "2026-08-29"},
)
conv = client.post(
f"/api/journal/days/{day.json()['day']['id']}/conversations",
headers=headers,
json={"title": "Tag"},
)
turn = client.post(
f"/api/journal/conversations/{conv.json()['id']}/turn",
headers=headers,
json={"body": f"Ich war am Markt. {TODAY_MARK}. Vielleicht bleibe ich kürzer."},
)
expect(turn.status_code == 200, f"turn {turn.status_code}")
day_id = day.json()["day"]["id"]
conv_id = conv.json()["id"]
def generate(voice_key: str) -> dict:
reset_debug()
recorder = install_test_recorder()
result = client.post(
f"/api/journal/days/{day_id}/generate",
headers=headers,
json={
"conversation_ids": [conv_id],
"generation_selection": selection_of(voice_key),
"remember_generation_selection": False,
},
)
expect(result.status_code == 200, f"{voice_key} generate {result.status_code}")
payload = result.json()
intern = intern_of(payload)
style = style_region(intern)
app_trace = (payload.get("trace") or {}).get("style_application") or {}
calls = [item.get("purpose") for item in recorder]
expect(calls == ["journal_generate"], f"{voice_key} still uses exactly one narration call")
expect(app_trace.get("id") == seed_id("voice", voice_key), f"{voice_key} id reaches the trace")
expect(app_trace.get("key") == voice_key, f"{voice_key} key reaches the trace")
expect(app_trace.get("revision") == 1, f"{voice_key} revision reaches the trace")
expect(
(payload.get("trace") or {}).get("generation_selection", {}).get("ids", {}).get("voice")
== seed_id("voice", voice_key),
f"{voice_key} selection id matches prompt/trace",
)
expect(TODAY_MARK not in style, f"{voice_key} does not use today as a style example")
expect(TODAY_MARK in intern.split("\nCURRENT_DAY_SOURCES\n")[-1], f"{voice_key} keeps today as content")
expect("keine neuen Tatsachen" in intern, f"{voice_key} keeps fact fidelity")
expect("[[PERSON:01]]" in intern, f"{voice_key} keeps privacy placeholders")
return {"payload": payload, "intern": intern, "style": style, "trace": app_trace}
free = generate("neutral")
expect(not has_heading(free["intern"], "WRITING_PROFILE"), "profile-free omits the profile heading")
expect(not has_heading(free["intern"], "STYLE_EXAMPLES"), "profile-free omits the examples heading")
expect("STYLE_EXAMPLES" not in free["intern"], "profile-free has no orphaned STYLE_EXAMPLES reference")
expect(CORE_MARK not in free["intern"], "profile-free sends no core")
expect(FACET_MARK not in free["intern"], "profile-free sends no facet")
expect(TRAIT_MARK not in free["intern"], "profile-free sends no traits")
expect(EXAMPLE_MARK not in free["intern"], "profile-free sends no style examples")
expect(free["trace"]["effective"]["include_core"] is False, "profile-free effective core is no")
expect(free["trace"]["effective"]["style_example_count"] == 0, "profile-free effective examples are zero")
light = generate("light")
expect(CORE_MARK in light["style"], "light sends core")
expect(FACET_MARK not in light["intern"], "light does not send facet even indirectly")
expect(TRAIT_MARK not in light["intern"], "light does not send traits even indirectly")
expect(EXAMPLE_MARK not in light["intern"], "light does not send examples even indirectly")
expect(has_heading(light["intern"], "WRITING_PROFILE"), "light keeps a profile heading")
expect(not has_heading(light["intern"], "STYLE_EXAMPLES"), "light omits the examples heading")
expect("STYLE_EXAMPLES" not in light["intern"], "light has no orphaned STYLE_EXAMPLES reference")
expect(light["trace"]["effective"]["include_core"] is True, "light effective core is yes")
expect(light["trace"]["effective"]["include_facet"] is False, "light effective facet is no")
expect(light["trace"]["effective"]["trait_count"] == 0, "light effective traits are zero")
clear = generate("clear")
expect(CORE_MARK in clear["style"], "clear sends core")
expect(FACET_MARK in clear["style"], "clear sends the relevant facet")
expect(TRAIT_MARK in clear["style"], "clear sends traits")
expect(EXAMPLE_MARK not in clear["intern"], "clear does not send examples")
expect(not has_heading(clear["intern"], "STYLE_EXAMPLES"), "clear omits the examples heading")
expect("STYLE_EXAMPLES" not in clear["intern"], "clear has no orphaned STYLE_EXAMPLES reference")
expect(clear["trace"]["effective"]["include_core"] is True, "clear effective core is yes")
expect(clear["trace"]["effective"]["include_facet"] is True, "clear effective facet is yes")
expect(clear["trace"]["effective"]["trait_count"] >= 1, "clear effective traits are counted")
expect(clear["trace"]["effective"]["style_example_count"] == 0, "clear effective examples are zero")
examples = generate("with_examples")
expect(CORE_MARK in examples["style"], "with-examples sends core")
expect(FACET_MARK in examples["style"], "with-examples sends facet")
expect(TRAIT_MARK in examples["style"], "with-examples sends traits")
expect(EXAMPLE_MARK in examples["style"], "with-examples sends style examples")
expect(has_heading(examples["intern"], "STYLE_EXAMPLES"), "with-examples labels the examples block")
expect("ausschließlich als Stilreferenz" in examples["intern"] or "sprachliche" in examples["style"], "examples stay style-only")
expect("keine Tatsachen des heutigen" in examples["style"] or "nicht übernommen" in examples["style"], "example facts are forbidden")
expect(examples["trace"]["effective"]["style_example_count"] >= 1, "with-examples effective count is used")
expect(EXAMPLE_MARK not in (examples["payload"].get("body") or ""), "example facts are not copied into today's draft")
settings = client.get("/api/journal/generation-settings", headers=headers).json()
voice_options = settings["options"]["voice"]
free_opt = next(item for item in voice_options if item["id"] == seed_id("voice", "neutral"))
expect(free_opt["style_context"]["include_core"] is False, "generation-settings expose style context without prompt text")
expect("instruction" not in free_opt, "generation-settings still hide prompt wording")
save_selection(profile_id, selection_of("neutral"))
remembered = generate("noticeable")
expect(CORE_MARK in remembered["style"], "legacy noticeable remains selectable")
profile_run = client.post(
f"/api/journal/days/{day_id}/generate",
headers=headers,
json={"conversation_ids": [conv_id]},
)
expect(profile_run.status_code == 200, f"stored selection generate {profile_run.status_code}")
stored_intern = intern_of(profile_run.json())
expect(not has_heading(stored_intern, "WRITING_PROFILE"), "stored profile-free selection stays profile-free")
expect(CORE_MARK not in stored_intern, "stored profile-free selection still omits core")
cloned = clone_guideline(seed_id("voice", "clear"))
original_instruction = get_guideline(seed_id("voice", "clear"), include_instruction=True)["instruction"]
with get_db() as conn:
seed_generation_instructions(conn)
expect(
get_guideline(seed_id("voice", "clear"), include_instruction=True)["instruction"] == original_instruction,
"seed refresh does not replace the published original with a clone",
)
expect(
get_guideline(cloned["id"], include_instruction=True)["style_context"]["include_core"] is True,
"cloned voice keeps the structured style context",
)
def compile_one(key: str) -> str:
bundle = compile_journal_style_application(profile_id, compile_selection(selection_of(key)).style_context)
return (bundle.get("brief") or "") + "\n" + (bundle.get("style_examples") or "")
with ThreadPoolExecutor(max_workers=2) as pool:
first = pool.submit(compile_one, "neutral")
second = pool.submit(compile_one, "with_examples")
free_text = first.result()
example_text = second.result()
expect(CORE_MARK not in free_text, "parallel profile-free compile stays empty")
expect(CORE_MARK in example_text and EXAMPLE_MARK in example_text, "parallel with-examples compile keeps all parts")
expect(EXAMPLE_MARK not in free_text, "parallel requests do not mix style configurations")
print("journal style context tests passed.")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,356 @@
"""Legacy comparison voices and immutable seed IDs after style-context control."""
from __future__ import annotations
import copy
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-journal-legacy-immutable-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, init_db
from journal_generation_policy import (
PURPOSE_JOURNAL,
archive_guideline,
clone_guideline,
compile_selection,
default_selection_ids,
get_guideline,
list_guidelines,
load_seed_document,
load_selection,
publish_guideline,
save_selection,
seed_generation_instructions,
update_guideline,
)
from journal_editorial import format_style_examples
from main import app
from privacy_gateway import install_test_recorder, reset_debug
# Reconstructed from git HEAD `backend/config/generation_instructions.seed.json`
# (commit 4f37991, seed_revision 2026-08-27-generation-guidelines-v1).
HEAD_VOICE = {
"neutral": {
"id": "journal-generate-voice-neutral",
"instruction": "Schreibe in einem neutralen Journalstil. Das Writing Profile höchstens als leise Tendenz.",
"label": "neutral",
"summary": "Neutraler Journalstil, Writing Profile nur als leise Tendenz.",
},
"light": {
"id": "journal-generate-voice-light",
"instruction": "Nimm Rhythmus und Wortwahl des Writing Profiles zurückhaltend auf.",
"label": "dezent",
"summary": "Rhythmus und Wortwahl des Writing Profiles zurückhaltend.",
},
"noticeable": {
"id": "journal-generate-voice-noticeable",
"instruction": "Wende das Writing Profile spürbar an, ohne es in den Vordergrund zu stellen.",
"label": "spürbar",
"summary": "Writing Profile spürbar, ohne in den Vordergrund zu treten.",
},
"clear": {
"id": "journal-generate-voice-clear",
"instruction": "Wende Rhythmus, Wortwahl und Reflexionsdichte des Writing Profiles deutlich an, ohne den Text künstlich zu literarisieren.",
"label": "deutlich",
"summary": "Rhythmus, Wortwahl und Reflexionsdichte des Writing Profiles deutlich.",
},
}
FULL_STYLE_CONTEXT = {
"include_core": True,
"include_facet": True,
"include_traits": True,
"include_style_examples": True,
}
CURRENT_VOICE_IDS = {
"neutral": "journal-generate-voice-neutral",
"light": "journal-generate-voice-light",
"noticeable": "journal-generate-voice-noticeable",
"clear": "journal-generate-voice-clear",
"with_examples": "journal-generate-voice-with-examples",
}
LEGACY_KEYS = ("legacy_neutral", "legacy_light", "legacy_noticeable", "legacy_clear")
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 intern_of(payload: dict) -> str:
for stage in (payload.get("trace") or {}).get("stages") or []:
if stage.get("purpose") == "journal_generate":
return stage.get("intern") or ""
return (payload.get("trace") or {}).get("intern") or ""
def seed_id(slot: str, key: str) -> str:
seed = load_seed_document()
for item in (seed.get("slots") or {}).get(slot, {}).get("variants") or []:
if item.get("guideline_key") == key:
return item["id"]
raise SystemExit(f"FAIL: missing seed {slot}/{key}")
def voice_rows() -> list[dict]:
return list_guidelines(PURPOSE_JOURNAL, slot="voice", include_instruction=True)
def snapshot_current_voices() -> dict:
return {
item["id"]: {
"instruction": item["instruction"],
"label": item["label"],
"summary": item["summary"],
"style_context": dict(item["style_context"]),
"is_default": item["is_default"],
"revision": item["revision"],
"status": item["status"],
}
for item in voice_rows()
if item["id"] in CURRENT_VOICE_IDS.values()
}
def test_current_variants_and_legacy_seed() -> None:
init_db()
before = snapshot_current_voices()
expect(CURRENT_VOICE_IDS["clear"] in before, "current default voice id remains")
expect(before[CURRENT_VOICE_IDS["clear"]]["is_default"] is True, "current default stays on Persönliche Stimme deutlich")
expect(
before[CURRENT_VOICE_IDS["neutral"]]["style_context"]["include_core"] is False,
"current profile-free voice keeps an empty style context",
)
expect(
default_selection_ids()["voice_id"] == CURRENT_VOICE_IDS["clear"],
"catalog default still points at the current clear id",
)
with get_db() as conn:
seed_generation_instructions(conn)
seed_generation_instructions(conn)
after = snapshot_current_voices()
expect(before == after, "reseeding does not change current voice ids or semantics")
legacy = [item for item in voice_rows() if item["guideline_key"] in LEGACY_KEYS]
expect(len(legacy) == 4, "legacy comparison voices are inserted once")
expect(len({item["id"] for item in legacy}) == 4, "legacy voices have distinct ids")
expect(all(not item["is_default"] for item in legacy), "legacy voices are not default")
expect(all(item["status"] == "active" for item in legacy), "legacy voices are selectable")
expect(
all(item["style_context"] == FULL_STYLE_CONTEXT for item in legacy),
"legacy voices reconstruct the former full style context",
)
by_key = {item["guideline_key"]: item for item in legacy}
for old_key, spec in HEAD_VOICE.items():
row = by_key[f"legacy_{old_key}"]
expect(row["id"] != spec["id"], f"legacy {old_key} uses a new stable id")
expect(row["instruction"] == spec["instruction"], f"legacy {old_key} keeps the reconstructed instruction")
expect("Legacy vor Stilkontextsteuerung" in row["label"], f"legacy {old_key} is labeled as pre-style-context")
expect(row["id"].startswith("journal-generate-voice-legacy-"), f"legacy {old_key} id is namespaced")
def test_stored_selection_and_clones_and_archives() -> None:
init_db()
profile_id = "legacy-immutable-user"
with get_db() as conn:
conn.execute(
"""
INSERT OR IGNORE INTO profiles (id, email, name, password_hash, role)
VALUES (?, ?, ?, ?, 'user')
""",
(profile_id, "legacy@example.test", "Legacy", "x"),
)
stored = default_selection_ids()
stored["voice_id"] = CURRENT_VOICE_IDS["neutral"]
save_selection(profile_id, stored)
cloned = clone_guideline(CURRENT_VOICE_IDS["clear"])
original_clone_instruction = "CLONE MUST STAY"
update_guideline(
cloned["id"],
{
"guideline_key": "clone_clear",
"label": "Klon",
"summary": "Admin-Klon",
"instruction": original_clone_instruction,
"style_context": {
"include_core": True,
"include_facet": False,
"include_traits": False,
"include_style_examples": False,
},
},
)
published_clone = publish_guideline(clone_guideline(CURRENT_VOICE_IDS["light"])["id"])
archived = archive_guideline(published_clone["id"])
archived_instruction = get_guideline(archived["id"], include_instruction=True)["instruction"]
archived_status = archived["status"]
with get_db() as conn:
seed_generation_instructions(conn)
expect(load_selection(profile_id)["voice_id"] == CURRENT_VOICE_IDS["neutral"], "stored selection is not rewritten")
expect(
get_guideline(cloned["id"], include_instruction=True)["instruction"] == original_clone_instruction,
"admin clone is not overwritten by seed",
)
expect(get_guideline(archived["id"], include_instruction=True)["status"] == archived_status, "archived variant stays archived")
expect(
get_guideline(archived["id"], include_instruction=True)["instruction"] == archived_instruction,
"archived variant is not rewritten",
)
expect(
get_guideline(CURRENT_VOICE_IDS["clear"], include_instruction=True)["is_default"] is True,
"current default is not reset by a legacy seed pass",
)
def test_later_semantic_seed_creates_successor() -> None:
init_db()
profile_id = "legacy-successor-user"
with get_db() as conn:
conn.execute(
"""
INSERT OR IGNORE INTO profiles (id, email, name, password_hash, role)
VALUES (?, ?, ?, ?, 'user')
""",
(profile_id, "succ@example.test", "Succ", "x"),
)
stored = default_selection_ids()
save_selection(profile_id, stored)
original = get_guideline(CURRENT_VOICE_IDS["clear"], include_instruction=True)
mutated = copy.deepcopy(load_seed_document())
for item in mutated["slots"]["voice"]["variants"]:
if item.get("id") == CURRENT_VOICE_IDS["clear"]:
item["instruction"] = "CHANGED FUTURE SEMANTICS FOR CLEAR"
item["label"] = "Persönliche Stimme deutlich (neu)"
break
mutated["seed_revision"] = "2026-08-30-future-voice-v1"
with get_db() as conn:
seed_generation_instructions(conn, mutated)
seed_generation_instructions(conn, mutated)
kept = get_guideline(CURRENT_VOICE_IDS["clear"], include_instruction=True)
expect(kept["instruction"] == original["instruction"], "future seed does not overwrite the existing variant id")
expect(kept["label"] == original["label"], "future seed does not change the existing label")
successors = [
item
for item in voice_rows()
if item.get("cloned_from") == CURRENT_VOICE_IDS["clear"]
and item["instruction"] == "CHANGED FUTURE SEMANTICS FOR CLEAR"
]
expect(len(successors) == 1, "changed semantics are inserted once as a successor")
successor = successors[0]
expect(successor["id"] != CURRENT_VOICE_IDS["clear"], "successor uses a new id")
expect(successor["revision"] == int(original["revision"]) + 1, "successor raises the revision")
expect(successor["cloned_from"] == CURRENT_VOICE_IDS["clear"], "successor records the predecessor")
expect(successor["is_default"] is True, "new default may point at the successor")
expect(kept["is_default"] is False, "previous default is released when the seed names a new default")
expect(
load_selection(profile_id)["voice_id"] == CURRENT_VOICE_IDS["clear"],
"stored selection keeps the previous id after a semantic successor is added",
)
compiled = compile_selection({**default_selection_ids(), "voice_id": CURRENT_VOICE_IDS["clear"]})
expect(compiled.ids["voice"] == CURRENT_VOICE_IDS["clear"], "previous id remains selectable with its old meaning")
expect(compiled.cloned_from["voice"] == "", "original current variant has no predecessor")
compiled_new = compile_selection({**default_selection_ids(), "voice_id": successor["id"]})
expect(compiled_new.cloned_from["voice"] == CURRENT_VOICE_IDS["clear"], "compiled policy exposes the predecessor")
def test_legacy_generate_trace_and_prompt() -> None:
Path(os.environ["KANSHO_DB_PATH"]).unlink(missing_ok=True)
init_db()
client = TestClient(app)
setup = client.post(
"/api/auth/setup",
json={"email": "legacy-gen@example.test", "password": "pass-pass", "name": "Legacy"},
)
expect(setup.status_code == 200, f"setup {setup.status_code}")
token = setup.json()["token"]
headers = header(token)
space = client.post("/api/journal/spaces", headers=headers, json={"title": "Urlaub"})
expect(space.status_code == 200, f"space {space.status_code}")
day = client.post(
f"/api/journal/spaces/{space.json()['id']}/days",
headers=headers,
json={"calendar_date": "2026-08-29"},
)
conv = client.post(
f"/api/journal/days/{day.json()['day']['id']}/conversations",
headers=headers,
json={"title": "Tag"},
)
turn = client.post(
f"/api/journal/conversations/{conv.json()['id']}/turn",
headers=headers,
json={"body": "Ich war am Markt. Vielleicht bleibe ich kürzer."},
)
expect(turn.status_code == 200, f"turn {turn.status_code}")
day_id = day.json()["day"]["id"]
conv_id = conv.json()["id"]
selection = default_selection_ids()
reset_debug()
recorder = install_test_recorder()
current = client.post(
f"/api/journal/days/{day_id}/generate",
headers=headers,
json={"conversation_ids": [conv_id], "generation_selection": selection, "remember_generation_selection": False},
)
expect(current.status_code == 200, f"current generate {current.status_code}")
current_payload = current.json()
current_intern = intern_of(current_payload)
current_trace = (current_payload.get("trace") or {}).get("style_application") or {}
expect([item.get("purpose") for item in recorder] == ["journal_generate"], "current path still uses one narration call")
expect(current_trace.get("id") == CURRENT_VOICE_IDS["clear"], "trace keeps the current voice id")
expect(current_trace.get("revision") == 1, "trace keeps the current revision")
expect("STYLE_EXAMPLES" not in current_intern, "current default prompt has no STYLE_EXAMPLES reference")
expect("\nSTYLE_EXAMPLES\n" not in current_intern, "current default prompt has no example block")
formatted = format_style_examples(
[{"kind": "journal_entry", "occurred_at": "2026-08-01", "excerpt": "Nur ein Stilbeispiel."}]
)
expect("keine Tatsachen des heutigen" in formatted or "nicht übernommen" in formatted, "examples stay style-only when present")
reset_debug()
recorder = install_test_recorder()
selection["voice_id"] = seed_id("voice", "legacy_neutral")
legacy = client.post(
f"/api/journal/days/{day_id}/generate",
headers=headers,
json={"conversation_ids": [conv_id], "generation_selection": selection, "remember_generation_selection": False},
)
expect(legacy.status_code == 200, f"legacy generate {legacy.status_code}")
payload = legacy.json()
intern = intern_of(payload)
app_trace = (payload.get("trace") or {}).get("style_application") or {}
expect([item.get("purpose") for item in recorder] == ["journal_generate"], "legacy path still uses one narration call")
expect(app_trace.get("id") == seed_id("voice", "legacy_neutral"), "legacy trace uses the reconstructed id")
expect(app_trace.get("revision") == 1, "legacy trace uses the reconstructed revision")
expect(app_trace.get("key") == "legacy_neutral", "legacy trace names the reconstructed key")
expect("höchstens als leise Tendenz" in intern, "legacy instruction reaches the provider prompt")
expect(app_trace.get("requested", {}).get("include_style_examples") is True, "legacy requests the former full style context")
def main() -> None:
test_current_variants_and_legacy_seed()
test_stored_selection_and_clones_and_archives()
test_later_semantic_seed_creates_successor()
test_legacy_generate_trace_and_prompt()
print("journal style legacy immutable tests passed.")
if __name__ == "__main__":
main()

View File

@ -327,7 +327,7 @@ def main() -> None:
"WRITING_PROFILE" in narrate_intern or "Neutraler Journalstil" in narrate_intern or "Core:" in narrate_intern,
"A style brief reaches generate",
)
expect("STYLE_EXAMPLES" in narrate_intern, "A style examples block is labeled")
expect("STYLE_EXAMPLES" not in narrate_intern, "A default voice does not attach a style-examples block")
style_part = narrate_intern.split("\nCURRENT_DAY_SOURCES\n")[0]
expect("Erzählmerkmale" not in style_part, "A current day dialogue is not appended as style signals")
expect("Neutraler Journalstil" in style_part or "Core:" in style_part, "A empty profile uses the neutral journal voice")
@ -516,7 +516,7 @@ def main() -> None:
if stage.get("purpose") == "journal_generate":
intern_i = stage.get("intern") or ""
intern_i = intern_i or ((gen_i.json().get("trace") or {}).get("intern") or "")
expect("STYLE_EXAMPLES" in intern_i, "I style examples reach generate")
expect("STYLE_EXAMPLES" not in intern_i, "I default voice does not send imported texts as style examples")
expect("WRITING_PROFILE" in intern_i or "Neutraler Journalstil" in intern_i, "I writing profile reaches generate")
expect("knapp" in imported.json()["compiled_brief"], "I stored brief still lists the import")
profile = client.get("/api/journal/writing-profile", headers=headers).json()

View File

@ -134,12 +134,15 @@ def main() -> None:
0,
)
expect(span.text == "Anna" and span.start == start, "valid span matches the local substring")
relocated = validate_detected_entity(
{"start": 0, "end": 4, "text": "Anna", "entity_type": "PERSON"},
sample,
0,
expect(
validate_detected_entity(
{"start": 0, "end": 4, "text": "Anna", "entity_type": "PERSON"},
sample,
0,
).start
== start,
"wrong offsets are grounded to the local word",
)
expect(relocated is not None and relocated.start == start and relocated.text == "Anna", "wrong offsets are grounded to the local word")
expect(
validate_detected_entity(
{"start": -1, "end": 4, "text": "Anna", "entity_type": "PERSON"},
@ -149,15 +152,20 @@ def main() -> None:
== start,
"negative offsets still ground when the word exists",
)
expect(
try:
validate_detected_entity(
{"start": 0, "end": 4, "text": "Nein", "entity_type": "PERSON"},
sample,
0,
)
is None,
"a word that is not in the chunk is skipped",
)
missing_word = False
except DetectError as exc:
missing_word = exc.code == "detect_invalid_output"
expect(
(exc.diagnostics or {}).get("contract_violation") == "unusable_offsets",
"a word that is not in the chunk is a contract violation",
)
expect(missing_word, "a word that is not in the chunk fails closed")
expect(
validate_detected_entity(
{"start": "x", "end": 4, "text": "Anna", "entity_type": "PERSON"},

View File

@ -0,0 +1,181 @@
"""Profile-review failures expose reason and whether the provider was billed.
Run from backend/: python tests/test_profile_review_errors.py
No live provider calls.
"""
from __future__ import annotations
import os
import sys
import tempfile
from pathlib import Path
from unittest.mock import patch
import httpx
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
os.environ["KANSHO_DB_PATH"] = str(Path(tempfile.gettempdir()) / "kansho-profile-review-errors-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 engine import EngineError
from main import app
from privacy_gateway import public_cost_report
from providers import ProviderConfig, complete_chat, provider_error_diagnostics
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 _openrouter() -> ProviderConfig:
return ProviderConfig(
role="generate",
name="openrouter",
mode="http",
url="https://openrouter.ai/api/v1/chat/completions",
model="openai/gpt-4o",
key="sk-test",
local=False,
zdr=True,
no_train=True,
)
def main() -> None:
none = public_cost_report({})
expect(none["billed"] == "no", "empty diagnostics are unbilled")
expect("keine Kosten entstanden" in none["note"], "empty diagnostics say no provider call")
detect_only = public_cost_report(
{"detect_calls": 2, "detect_cost": 0.0012, "generate_called": False, "generate_requested": False}
)
expect(detect_only["billed"] == "yes", "detect cost counts as billed")
expect("Detect" in detect_only["note"] and "Generate nicht" in detect_only["note"], "detect-only note names both stages")
sent = public_cost_report(
{"detect_calls": 1, "detect_cost": 0.0004, "generate_requested": True, "generate_called": False}
)
expect(sent["billed"] == "yes", "known detect cost stays billed if generate is unknown")
expect("Generate-Aufruf gesendet" in sent["note"], "unknown generate request is explicit")
unknown = public_cost_report({"generate_requested": True, "generate_called": False})
expect(unknown["billed"] == "unknown", "generate request without any usage is unknown")
known = public_cost_report(
{"detect_calls": 1, "detect_cost": 0.0004, "generate_called": True, "cost": 0.021}
)
expect(known["billed"] == "yes", "generate usage is billed")
expect(abs((known["total_cost"] or 0) - 0.0214) < 1e-9, "total adds detect and generate")
parsed = provider_error_diagnostics(
402,
'{"error":{"message":"Insufficient credits","code":402},"usage":{"cost":0.0}}',
)
expect(parsed["http_status"] == 402, "provider diagnostics keep HTTP status")
expect(parsed["provider_message"] == "Insufficient credits", "provider message is copied")
expect(parsed.get("cost") == 0.0, "error usage cost is kept")
class Reject:
status_code = 402
text = '{"error":{"message":"Insufficient credits","code":402}}'
def json(self):
return {"error": {"message": "Insufficient credits", "code": 402}}
try:
with patch("providers.httpx.post", lambda *args, **kwargs: Reject()):
complete_chat(_openrouter(), [{"role": "user", "content": "x"}], timeout=5)
raise SystemExit("FAIL: rejected complete_chat should raise")
except Exception as exc:
expect(getattr(exc, "code", "") == "provider_rejected", "credit reject is provider_rejected")
expect("HTTP 402" in str(exc), "reject message includes HTTP status")
expect("Insufficient credits" in str(exc), "reject message includes provider reason")
expect(exc.diagnostics.get("generate_requested") is True, "reject marks generate as requested")
def boom(*args, **kwargs):
raise httpx.TimeoutException("timed out")
try:
with patch("providers.httpx.post", boom):
complete_chat(_openrouter(), [{"role": "user", "content": "x"}], timeout=12)
raise SystemExit("FAIL: timeout should raise")
except Exception as exc:
expect(getattr(exc, "code", "") == "provider_timeout", "timeout is distinct from unreachable")
expect("12 Sekunden" in str(exc), "timeout names the limit")
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, f"setup {setup.text}")
token = setup.json()["token"]
headers = header(token)
imported = client.post(
"/api/journal/writing-profile/import",
headers=headers,
json={"body": "Heute Kirschen am Markt, später der Hafen, und abends nur zwei knappe Zeilen."},
)
expect(imported.status_code == 200, f"import {imported.text}")
rejected = EngineError(
"provider_rejected",
"generate-Provider hat die Anfrage abgelehnt (HTTP 402): Insufficient credits",
502,
diagnostics={
"http_status": 402,
"provider_message": "Insufficient credits",
"generate_requested": True,
"generate_called": False,
"detect_calls": 1,
"detect_cost": 0.0015,
},
)
with patch("engine.execute_prompt", side_effect=rejected):
response = client.post("/api/journal/writing-profile/initial-build/api", headers=headers)
expect(response.status_code == 502, f"rejected review status {response.text}")
detail = response.json()["detail"]
expect(detail["code"] == "provider_rejected", "API keeps provider code")
expect("Insufficient credits" in detail["message"], "API message keeps provider reason")
expect(detail["cost_report"]["billed"] == "yes", "detect cost is reported as billed")
expect("Detect" in detail["cost_report"]["note"], "cost note mentions detect")
expect(detail["cost_report"]["generate_requested"] is True, "generate was requested")
expect(detail["cost_report"]["generate_called"] is False, "generate did not complete")
invalid = {
"content": "kein json",
"diagnostics": {
"generate_called": True,
"generate_requested": True,
"cost": 0.033,
"detect_calls": 1,
"detect_cost": 0.002,
},
"trace": {"generate_called": True, "purpose": "profile_review"},
}
with patch("engine.execute_prompt", return_value=invalid):
response = client.post("/api/journal/writing-profile/review/api", headers=headers)
expect(response.status_code == 400, f"invalid result status {response.text}")
detail = response.json()["detail"]
expect(detail["code"] == "invalid_review_result", "parse failure keeps store code")
expect("JSON" in detail["message"] or "json" in detail["message"].lower(), "parse failure explains JSON")
expect(detail["cost_report"]["billed"] == "yes", "successful generate before parse failure is billed")
expect(detail["cost_report"]["generate_called"] is True, "parse failure still records generate")
expect(abs((detail["cost_report"]["cost"] or 0) - 0.033) < 1e-9, "generate cost survives parse failure")
print("ALL TESTS PASSED")
if __name__ == "__main__":
main()

View File

@ -1186,23 +1186,34 @@ def _assemble_brief(profile_id: str) -> None:
)
def compile_task_brief(profile_id: str, task: str = "journal_generate") -> str:
def compile_task_brief(
profile_id: str,
task: str = "journal_generate",
*,
include_core: bool = True,
include_facet: bool = True,
include_traits: bool = True,
) -> str:
"""Compact confirmed style brief. Historical texts are selected separately.
Unconfirmed profiles do not become a style authority. Journal adapters attach
final entries as STYLE_EXAMPLES, not as extra rules in this brief.
final entries as STYLE_EXAMPLES, not as extra rules in this brief. Callers may
omit core, facet or traits; omitted parts are not rendered as empty headings.
"""
profile = get_profile(profile_id)
if task != "journal_generate":
core = (profile.get("core") or {}).get("value") or ""
return _clip_field(core, TASK_BRIEF_MAX_CHARS)
if not include_core and not include_facet and not include_traits:
return ""
if not has_confirmed_profile(profile_id):
return NEUTRAL_JOURNAL_STYLE
parts: list[str] = []
core_text = ((profile.get("core") or {}).get("value") or "").strip()
if core_text:
core_text = ((profile.get("core") or {}).get("value") or "").strip() if include_core else ""
if include_core and core_text:
clipped = _clip_field(core_text, TASK_BRIEF_CORE_CHARS)
if clipped:
parts.append("Core: " + clipped)
@ -1214,13 +1225,14 @@ def compile_task_brief(profile_id: str, task: str = "journal_generate") -> str:
),
None,
)
facet_text = ((facet or {}).get("value") or "").strip()
if facet_text and _norm_overlap(facet_text, core_text) < 0.8:
facet_text = ((facet or {}).get("value") or "").strip() if include_facet else ""
if include_facet and facet_text and _norm_overlap(facet_text, core_text) < 0.8:
clipped = _clip_field(facet_text, TASK_BRIEF_FACET_CHARS)
if clipped:
parts.append("Autobiografisches Journaling (Facet-Delta): " + clipped)
selected = _select_task_traits(profile.get("traits") or [])
selected = _select_task_traits(profile.get("traits") or []) if include_traits else []
used_statements = [core_text, facet_text]
trait_lines: list[str] = []
for item in selected:
statement = (item.get("statement") or "").strip()
if any(_norm_overlap(statement, previous) >= 0.85 for previous in used_statements if previous):
@ -1228,8 +1240,9 @@ def compile_task_brief(profile_id: str, task: str = "journal_generate") -> str:
clipped = _clip_field(statement, TASK_BRIEF_TRAIT_CHARS)
if not clipped:
continue
parts.append(f"- {item.get('label') or item.get('slug')}: {clipped}")
trait_lines.append(f"- {item.get('label') or item.get('slug')}: {clipped}")
used_statements.append(statement)
parts.extend(trait_lines)
brief = "\n".join(part for part in parts if part).strip()
while len(brief) > TASK_BRIEF_MAX_CHARS and parts:
last = parts.pop()
@ -1240,7 +1253,9 @@ def compile_task_brief(profile_id: str, task: str = "journal_generate") -> str:
brief = "\n".join(parts).strip()
break
brief = "\n".join(parts).strip()
return brief or NEUTRAL_JOURNAL_STYLE
if include_core and include_facet and include_traits:
return brief or NEUTRAL_JOURNAL_STYLE
return brief
def has_facets(profile_id: str) -> bool:

View File

@ -0,0 +1,430 @@
Du arbeitest im Kanshō-Repository als **fachlicher Konzept-Worker**.
Deine Aufgabe ist ausdrücklich **nicht**, Slice 2 zu implementieren oder technische Detailarchitektur vorwegzunehmen. Du sollst auf Basis der bestehenden kanonischen Fachkonzepte einen **fachlich belastbaren Vorschlag und Anforderungsschnitt für den zweiten vertikalen Produktslice** erarbeiten, der anschließend als Grundlage für einen Coding Agent dienen kann.
## Ausgangslage
Der erste MVP-Slice ist abgeschlossen.
Er realisiert einen journalzentrierten Anwendungsfall innerhalb eines Reflection Space:
- Beispiel Reflection Space: `Veli Lošinj 2026`
- pro Tag kann es einen Journal Day geben
- pro Tag können mehrere Conversations/Chats geführt werden
- der Nutzer kann entscheiden, welcher Dialog bzw. welche Dialoge als Inputquelle für die Journalgenerierung verwendet werden
- der ausgewählte Dialog wird **nicht direkt als Tagebucheintrag übernommen**
- aus dem Dialog wird mehrstufig mit Hilfe der KI und des persönlichen Writing Profile ein persönlicher Journal Draft generiert
- dieser Draft kann anschließend redigiert und als Journal Entry übernommen werden
- das Ergebnis soll insbesondere bei Reise-/Urlaubstagebüchern eine zusammenhängende, menschenlesbare autobiografische Erinnerung ergeben
Diese Fähigkeit ist wertvoll und darf durch Slice 2 **nicht verschlechtert oder fachlich entwertet** werden.
Gleichzeitig bildet dieser MVP nur eine Teilmenge des Kanshō-Zielbilds ab.
Kanshō soll langfristig ein **Personal Reflection Companion** sein und nicht auf Tagesjournal bzw. Tagebuch beschränkt bleiben.
## Bestehendes fachliches Zielbild
Die vorhandenen Fachkonzepte unterscheiden insbesondere:
### Reflection Space
Der Reflection Space beschreibt den Bedeutungs-, Lebens- oder Erfahrungskontext.
Beispiele:
- `Urlaub → Veli Lošinj 2026`
- persönliche Entwicklung
- Beruf
- Familie
- ein konkretes längerfristiges persönliches Thema
Ein Reflection Space ist kein bloßer Ordner und kein Intent. Er bildet für den Nutzer einen verständlichen Denk- und Erfahrungsraum und soll ein wesentliches menschliches Ordnungsprinzip bleiben.
### Nutzungssituation / Reflection Intent
Die bestehende Fachkonzeption beschreibt elf vorläufig vollständige Nutzungssituationen:
1. Erleben festhalten
2. Tagesreflexion
3. Spontaner Gedanke / Moment
4. Früheren Faden fortsetzen
5. Tiefe Reflexion / biografische Frage
6. Entscheidung / Orientierung
7. Inspiration / Vision / Kreativität
8. Achtsamkeit / Meditation / Check-in
9. Rückblick und persönliche Entwicklung
10. Geplante Reflexionszeit / Ritual
11. Wiederfinden und Revue passieren
Diese Nutzungssituationen sind **keine elf Reflection Spaces und keine Verpflichtung zu elf technischen Intent Engines**.
Sie beschreiben, was der Nutzer in einer konkreten Interaktion gerade erreichen möchte.
Der Reflection Intent kann:
- explizit durch den Nutzer gesetzt werden,
- von Kanshō aus Einstieg und Kontext erkannt werden,
- sich im Verlauf derselben Conversation verändern.
Ein Intent-Wechsel soll einen natürlichen Dialog nicht automatisch in mehrere Chats zerlegen.
### Threads
Eine Conversation kann mehrere Reflexionsfäden enthalten.
Threads dienen langfristiger inhaltlicher Kontinuität und können über Conversations, Tage und ggf. Reflection Spaces hinweg weiterlaufen.
Nicht jedes Seitenthema soll automatisch einen sichtbaren oder langlebigen Thread erzeugen.
### Memory und Context
Die bestehende Baseline lautet insbesondere:
- aktueller Dialog zuerst
- Primary Reflection Space vor globalem Langzeitgedächtnis
- Long-Term Memory nur bei tatsächlichem Bedarf
- Minimum sufficient context
- Retrieval findet Kandidaten; Context-Auswahl entscheidet Relevanz
- semantische Ähnlichkeit allein ist kein Relevanznachweis
- Source bleibt kanonisch
- Derived ersetzt Source nicht
- Current Validity und History bleiben getrennt
- Nutzerkorrekturen haben Vorrang
- langfristige Dialogkontinuität muss unter Kontrolle von Kanshō bleiben
Die bestehende fachliche Eskalation ist sinngemäß:
`Current Session → Primary Reflection Space → aktive Threads / offene Fragen → relevanter Reflection Context → relevantes Kanshō Long-Term Memory → mindnet → Original Sources / Re-Grounding`
### Reflection Outputs
Ein Dialog kann mehrere unterschiedliche Ergebnisse erzeugen.
Beispielsweise:
- Journal Entry
- Reflection Memory
- Knowledge Delta
- Action Candidate
- interne Kontinuitätsstrukturen wie Thread Memory oder offene Fragen
Nicht jede Conversation muss ein sichtbares Output-Artefakt erzeugen.
## Zentrale Fragestellung für Slice 2
Der nächste Slice soll das heute journalzentrierte System zu einem allgemeineren dialogischen Reflexionsbegleiter weiterentwickeln.
Dabei muss insbesondere geprüft werden:
> Wie kann ein Reflection Space unterschiedliche Reflection Intents und natürlich gemischte Conversations tragen, ohne dass dadurch die heute bereits gut funktionierende Fähigkeit verloren geht, aus einem oder mehreren ausgewählten Dialogen einen kohärenten persönlichen Tages- bzw. Reisetagebucheintrag zu generieren?
Beispiel:
Im Reflection Space `Veli Lošinj 2026` beginnt eine Conversation mit der Beschreibung des Urlaubstags.
Während des Gesprächs sagt der Nutzer beispielsweise:
> „Mir fällt auf, dass ich hier im Urlaub viel bewusster lebe als zu Hause.“
Daraus kann sich eine längere tiefere Reflexion entwickeln.
Danach erzählt der Nutzer möglicherweise wieder weiter vom Tagesgeschehen.
Die Conversation enthält damit mindestens:
- autobiografisches Tageserleben
- persönliche Reflexion
- möglicherweise einen längerfristig relevanten Reflexionsfaden
Trotzdem soll aus diesem Dialog weiterhin ein **guter zusammenhängender Journal Entry über den Urlaubstag** generiert werden können.
Dabei darf weder gelten:
- „der vollständige Dialog muss ungefiltert in den Journalentwurf einfließen“
noch:
- „alles, was während eines anderen Reflection Intents gesagt wurde, darf grundsätzlich nicht in das Journal“
Denn eine persönliche Reflexion kann sehr wohl ein wichtiger Teil der autobiografischen Erinnerung dieses Tages sein.
## Deine Aufgabe
Erarbeite daraus einen **fachlichen Vorschlag für Slice 2**.
Prüfe dabei insbesondere folgende Punkte.
### 1. Produktthese des Slices
Formuliere präzise:
- welche neue Nutzerfähigkeit Slice 2 beweisen soll,
- warum diese nach dem Journal-MVP der sinnvollste nächste vertikale Schritt ist,
- welche Teile des Kanshō-Zielbilds dadurch erstmals real validiert werden.
Vermeide einen rein technischen „Memory-Ausbau“ als Produktthese.
### 2. Reflection Space als Ordnungsrahmen
Prüfe und konkretisiere:
- welche Rolle Reflection Spaces im Slice spielen,
- wie sie für den Nutzer als nachvollziehbares Ordnungsprinzip erhalten bleiben,
- wie Conversations mit unterschiedlichen Intents innerhalb desselben Space organisiert werden,
- wie verhindert wird, dass Threads, Memories, Intents und Derived Objects zu einer unübersichtlichen sichtbaren Struktur führen.
Beachte dabei die bestehende Leitlinie:
> interne Reichhaltigkeit darf deutlich größer sein als die sichtbare Nutzerstruktur.
### 3. Multi-Intent Conversation
Definiere fachlich:
- wie der initiale Reflection Intent entsteht,
- wie expliziter Nutzer-Intent und automatische Erkennung zusammenspielen,
- wie sich Intent innerhalb derselben Conversation verändern darf,
- welche Auswirkungen Intent auf Dialogtiefe, Richtung, Initiative und Kontextnutzung besitzt,
- welche Zustände tatsächlich langlebig gespeichert werden müssen und welche nur operative Dialogsteuerung sein können.
Lege **nicht vorschnell eine neue reduzierte Intent-Taxonomie** fest.
Wenn mehrere fachliche Intents dieselbe technische Dialog-/Context-Policy verwenden könnten, darfst du dies als spätere technische Verdichtung benennen. Die elf fachlichen Nutzungssituationen sollen dadurch jedoch nicht stillschweigend ersetzt werden.
### 4. Erhalt des Journal-Workflows
Dies ist ein **zentrales Acceptance-Thema des Slices**.
Beschreibe fachlich, wie der bestehende Workflow erhalten bleiben kann:
`Reflection Space → Journal Day → ein oder mehrere Dialoge als Input auswählen → mehrstufige KI-Generierung mit Writing Profile → Journal Draft → Nutzerredaktion → Journal Entry`
Berücksichtige dabei insbesondere gemischte Conversations.
Klärungsbedarf:
- Wie entscheidet sich fachlich, welche Inhalte eines gemischten Dialogs für einen konkreten Journal Draft relevant sind?
- Welche Rolle spielen Reflection Intent, Tagesbezug, Ereignisbezug, Nutzerwahl und andere Signale?
- Kann ein und derselbe Gesprächsabschnitt für mehrere fachliche Ergebnisse relevant sein?
- Wie bleibt der Originaldialog die Quelle?
- Welche fachliche Provenance zwischen Quelle und Output wird benötigt?
- Muss dafür ein eigenes langlebiges Segmentobjekt entstehen oder reicht fachlich zunächst die Fähigkeit, Outputs auf relevante Message-/Source-Bereiche zurückzuführen?
- Welche dieser Fragen muss Slice 2 tatsächlich lösen und welche darf offen bleiben?
Wichtig:
Der Nutzer soll weiterhin auf grober Ebene entscheiden können, welche Conversation(s) eines Tages als Inputquelle für den Journal Draft dienen.
Kanshō darf innerhalb dieser Quellen eine feinere inhaltliche Relevanzbewertung übernehmen, ohne daraus eine komplexe Segmentverwaltungsoberfläche für den Nutzer zu machen.
### 5. Threads und Kontinuität
Bestimme den minimal notwendigen Umfang für Slice 2.
Prüfe:
- wann ein Nebenfaden lediglich temporärer Gesprächskontext bleibt,
- wann daraus ein Thread Candidate entstehen kann,
- wann ein langlebiger Thread erforderlich ist,
- wie ein bereits bestehender Thread später fortgesetzt werden kann,
- welches minimale Thread Memory dafür benötigt wird.
Vermeide eine vollständige Thread-Ontologie oder umfangreiche automatische Thread-Verwaltung, wenn sie für diesen Slice nicht erforderlich ist.
### 6. Memory und Context Builder
Beschreibe den minimal notwendigen fachlichen Memory-Ausbau.
Insbesondere:
- welche Memory-/Context-Schichten Slice 2 tatsächlich benötigt,
- wie der Reflection Intent die Context-Auswahl beeinflusst,
- welche früheren Informationen bei unterschiedlichen Intents eher relevant sind,
- wie `Reflection Space first, Long-Term Memory on demand` erhalten bleibt,
- wie verhindert wird, dass globale autobiografische Suche zum Normalfall jedes Turns wird.
Prüfe ausdrücklich, welche Fähigkeiten **noch nicht** notwendig sind.
### 7. Persistenz und Outputs
Klär fachlich, welche langlebigen Ergebnisse Slice 2 benötigt.
Beispielsweise:
- Journal Entry
- Thread Memory
- Open Question
- Reflection Memory / Insight
- ggf. andere Derived Objects
Nicht jedes mögliche Target-Objekt soll automatisch Bestandteil des Slices werden.
Für jedes vorgeschlagene langlebige Objekt beantworte:
- Warum benötigt der aktuelle Slice dieses Objekt jetzt?
- Würde ein späteres Nachrüsten sonst eine grundlegende Refaktierung erzwingen?
- Ist es sichtbares Nutzerartefakt oder primär interne Kontinuitätsstruktur?
Beachte die Implementation Foundation:
> Neue Abstraktionen benötigen aktuellen Nutzen.
### 8. Current Validity, Correction und Provenance
Sobald Derived Memory produktiv für spätere Dialoge verwendet wird, müssen mindestens die bestehenden Foundation-Regeln eingehalten werden:
- Source bleibt kanonisch
- Derived ersetzt Source nicht
- Zeit und Provenance
- Current Validity vs. History
- User Correction > Model Inference
Prüfe, welcher **minimale fachliche Korrektur- und Re-Grounding-Umfang** für Slice 2 wirklich notwendig ist.
Baue daraus nicht automatisch eine umfangreiche Provenance- oder Memory-Administration als Produktfeature.
### 9. Auswahl der Nutzungssituationen für Slice 2
Mappe alle elf bestehenden Nutzungssituationen auf:
- bereits durch MVP abgedeckt / Regression Case
- Bestandteil Slice 2
- bewusst später
Begründe die Auswahl.
Prüfe insbesondere, ob für Slice 2 zusätzlich zum bestehenden Journalpfad folgende Situationen geeignet sind:
- Spontaner Gedanke / Moment
- Früheren Faden fortsetzen
- Tiefe Reflexion / biografische Frage
- Entscheidung / Orientierung
Übernimm diese Auswahl aber nicht ungeprüft. Wenn du einen engeren oder besseren vertikalen Schnitt begründen kannst, schlage ihn vor.
### 10. Acceptance Scenarios
Definiere wenige, aber harte End-to-End-Szenarien.
Mindestens zu prüfen:
#### A. Bestehendes Reisetagebuch als Regression
Space `Veli Lošinj 2026`.
Der Nutzer führt einen überwiegend autobiografischen Tagesdialog.
Der bestehende Journalworkflow funktioniert weiterhin mindestens so gut wie im MVP.
#### B. Gemischter Urlaubsdialog
Ein Dialog beginnt mit Tageserleben, geht in tiefere persönliche Reflexion über und kehrt anschließend zum Tageserleben zurück.
Aus dem Dialog kann weiterhin ein kohärenter persönlicher Journal Draft entstehen.
Die tiefere Reflexion darf dabei weder ungeprüft vollständig in das Journal übernommen noch pauschal ausgeschlossen werden.
#### C. Freie Nicht-Journal-Reflexion
Der Nutzer beginnt innerhalb eines Reflection Space mit einer persönlichen Frage.
Kein Journal Day ist zwingend erforderlich.
Kanshō führt intentgerecht einen tieferen Reflexionsdialog.
#### D. Früheren Faden fortsetzen
Nach mehreren Tagen wird ein bestehender relevanter Reflexionsfaden fortgesetzt.
Der Nutzer muss den bisherigen Denkstand nicht erneut vollständig erklären.
#### E. Intent-Wechsel
Der Intent verändert sich innerhalb derselben Conversation, ohne dass der Nutzer einen neuen Chat starten muss.
#### F. Kein relevantes Memory
Kanshō führt einen natürlichen Dialog, ohne künstlich frühere Erinnerungen oder Zusammenhänge anzudeuten.
### 11. Explizite Non-Goals
Grenze den Slice klar gegen das spätere Zielmodell ab.
Prüfe insbesondere als Non-Goals:
- vollständiges Self Model
- langfristige automatische Pattern-Erkennung
- globales semantisches Retrieval über die gesamte Biografie
- vollständiger Reflection Graph
- komplexes Resurfacing
- umfassende Thread-Ontologie
- automatische Promotion jedes Gedankens
- mindnet-/Kairo-/Obsidian-Vollintegration
- Meditation/Achtsamkeit als eigener großer Interaction Slice
- vollständiger Entwicklungsrückblick
- Revue-/Recall-System
- elf technische Intent Engines
### 12. Auswirkungen auf bestehende Fachkonzepte
Prüfe zum Abschluss:
- welche bestehenden Fachentscheidungen der Slice lediglich konkretisiert,
- wo tatsächlich eine neue fachliche Entscheidung erforderlich ist,
- welche Dokumente später additiv aktualisiert werden müssten,
- ob irgendwo bestehende Konzepte widersprüchlich oder für den Slice noch unzureichend sind.
Ändere keine kanonischen Dateien in diesem Arbeitsschritt.
## Zu lesende kanonische Grundlage
Lade mindestens:
- `docs/architecture/functional/produktvision_und_produktidentitaet.md`
- `docs/architecture/functional/usage_situations.md`
- `docs/architecture/functional/reflection_spaces.md`
- `docs/architecture/functional/dialogue_model.md`
- `docs/architecture/functional/memory_and_context.md`
- `docs/architecture/functional/reflection_outputs.md`
- `docs/architecture/functional/implementation_foundation.md`
Zusätzlich gezielt, soweit für konkrete Entscheidungen erforderlich:
- `resurfacing_and_saturation.md`
- `context_fidelity_and_regrounding.md`
- `writing_profile_and_journaling.md`
- `mvp.md`
- aktuelle technische MVP-Dokumentation beziehungsweise Code nur zur Prüfung von Ist-Stand und Migrationsrisiko
Verwende `mvp_stand_und_abgleich.md` nur mit Vorsicht als historischen Snapshot, falls der aktuelle Repo-Stand inzwischen weiterentwickelt wurde.
## Erwartetes Ergebnis
Liefere einen strukturierten fachlichen Slice-Vorschlag mit:
1. Executive Summary
2. Produktthese
3. fachlichem Zielbild des Slices
4. Fit-Gap zum abgeschlossenen Journal-MVP
5. Mapping der elf Nutzungssituationen
6. Rollen von Space, Conversation, Intent, Thread, Memory und Output
7. Journal-Regressionsmodell einschließlich gemischter Conversations
8. minimalem Memory-/Continuity-Umfang
9. notwendigen fachlichen Objekten und Beziehungen
10. Acceptance Scenarios und Acceptance Criteria
11. expliziten Non-Goals
12. offenen Produktentscheidungen
13. empfohlenen Dokumentationsänderungen
14. klarer Empfehlung, ob dieser Slice der optimale nächste vertikale Schritt ist oder ob ein kleinerer/besserer Schnitt vorzuziehen ist
Wichtig:
- Keine Implementierung beginnen.
- Keine Dateien verändern.
- Keine Target Capabilities allein deshalb vorbauen, weil sie im Gesamtmodell existieren.
- Fachliche Anforderungen und technische mögliche Umsetzung klar trennen.
- Bestehende Entscheidungen nicht stillschweigend ersetzen.
- Bei Widersprüchen zwischen Dokumenten diese explizit benennen.
- Den bestehenden Reisetagebuch-Workflow als harte Regression behandeln.
- Ziel ist ein **umsetzbarer vertikaler Slice**, nicht eine neue vollständige Kanshō-Gesamtarchitektur.

View File

@ -1,7 +1,7 @@
---
title: "Kanshō Dokumentationsindex und Context Bundles"
status: "Arbeitsstand"
date: "2026-08-18"
date: "2026-09-07"
product_family: "Jinkendo"
document_role: "Documentation Index / Context Loading Guide"
parent_document: "fachliche_zielarchitektur.md"
@ -13,7 +13,7 @@ Dieses Dokument dient dazu, für weitere Konzeptarbeit nur die tatsächlich ben
## 1. Root-Dokumente
- `handover.md` Session-Bootstrap für die **Konzept-/Interviewarbeit**; nicht der Code-Stand. Implementierungsstand und Fit-Gap: `mvp_stand_und_abgleich.md`.
- `handover.md` Session-Bootstrap für die **Konzept-/Interviewarbeit**; nicht der Code-Stand. Implementierungsstand und Fit-Gap: `mvp_stand_und_abgleich.md`. Umgebungswechsel Laptop → Heim/Server: `../technical/environment_handover.md`.
- `fachliche_zielarchitektur.md` Governance, Dokumentationsprinzipien, Querschnittsinvarianten, Dateistruktur.
- `produktvision_und_produktidentitaet.md` Vision, Identität, Scope, Produktprinzipien und Nicht-Ziele.
- `interview_plan.md` Kapitelplan, Interviewmethode, Fortschritt und nächster Interviewblock.
@ -103,6 +103,8 @@ Sie übernimmt den Produktrahmen **weitgehend von Mitai**. Datenschutz, Privacy
Technische Umsetzung des gebauten Journal-Slices: `../technical/mvp_implementation.md`. Fit-Gap bleibt fachlich: `mvp_stand_und_abgleich.md`.
Arbeitsaufträge (kein Ersatz kanonischer Kapitel): `docs/work_orders/` Laptop-Abschluss, Heim-Setup, Debug-Export, Dialog/Memory-Slice. Slice-2-Konzeptbrief derzeit `Idea_seconmd_sclide.md`.
## 5. Ladeprinzip
> **Nicht alle Kanshō-Dokumente gleichzeitig laden.** Root-Dokumente plus die 24 fachlich betroffenen Dateien bilden den Standardkontext.

View File

@ -15,7 +15,9 @@ canonical_docs_path: "docs/architecture/functional/"
Dieses Dokument ist der **vollständige fachliche Aufsatzpunkt für eine neue Kanshō-Konzept-Session**.
**Implementierungsstand (Code)** steht nicht hier, sondern in `mvp_stand_und_abgleich.md` (2026-08-25): Slice gegen Foundation, MVP-Text und Gesamtziel. Dieses Handover bleibt der Konzept-/Interview-Bootstrap.
**Implementierungsstand (Code)** steht nicht hier, sondern in `mvp_stand_und_abgleich.md` (2026-08-25, Opening-Additiv 2026-09-03/07): Slice gegen Foundation, MVP-Text und Gesamtziel. Dieses Handover bleibt der Konzept-/Interview-Bootstrap.
**Umgebungswechsel Laptop → Heim/Server (2026-09-07):** nicht hier, sondern `docs/architecture/technical/environment_handover.md` plus `docs/architecture/technical/runtime_and_deploy.md` §7.2. Opening-Invariante (kein Folgetag aus Entry-Prefix): `mvp.md` §6.1.
Es soll ermöglichen, die Arbeit ohne Rückgriff auf den langen bisherigen Chat fortzusetzen und dabei insbesondere verhindern, dass:

View File

@ -388,7 +388,11 @@ Der erste Gesprächsimpuls unterliegt derselben Dialog-Policy wie spätere Folge
**Status: Code vorhanden, journalspezifisch, keine universelle Continuation-Engine.**
Ein neu gestarteter Journal-Dialog erhält einen ersten Kanshō-Impuls. Belegte Vorhaben und offene user-Punkte desselben Journal Day dürfen den Einstieg stützen. Recency allein ist kein Muster. Assistententext ist Kontext, kein Nutzerfakt. Ohne relevanten Kontext: kurzer neutraler lokaler Einstieg. Fehlgeschlagener Modelloutput speichert keine beschädigte Nachricht.
Ein neu gestarteter Journal-Dialog erhält einen ersten Kanshō-Impuls. Belegte Vorhaben dürfen den Einstieg stützen, wenn sie aus dem **user-Dialog des unmittelbar vorigen Kalendertags** stammen. Offene user-Punkte gelten nur für denselben Journal Day. Recency allein ist kein Muster. Ein gespeicherter Journal-Eintrag ist abgeschlossene Erzählung, kein offenes Vorhaben; ein 400-Zeichen-Entry-Ausschnitt darf keinen Folgetag als offenen Faden führen. Assistententext ist Kontext, kein Nutzerfakt. Ohne relevanten Kontext: kurzer neutraler lokaler Einstieg. Fehlgeschlagener Modelloutput speichert keine beschädigte Nachricht.
**Additiv 2026-09-03:** Der Opening-Pfad lädt Space-Recency nicht in den Modellkontext. Gestern angekündigte Vorhaben bleiben ein Folgetag lang zulässig; ältere oder nur im Entry vorkommende Formulierungen („auf den nächsten Tag“) erzeugen den neutralen Einstieg.
**Additiv 2026-09-07 (Fehlerbild, ohne Thread-Engine):** Ein nachgetragener Morgenanker (späte user-Zeile mit früher Uhrzeit) kann im Journal am Textanfang landen. Ein festes Prefix (400 Zeichen) liest dann Überlegung ohne späteren Vollzug und erzeugt tagelang denselben Einstieg. Das ist kein Primär-Thread, sondern Recency plus Plan-Regex. **Bewusst offen:** dieselben 400-Zeichen-Ausschnitte bleiben im *laufenden* Dialogzug (`dialogue_turn`) als Hinweis geladen; sie sind kein Opening-Mandat mehr. Entfernen oder Relevanz statt Prefix ist Slice-2-/Memory-Thema, nicht Opening-Regression.
---
@ -439,6 +443,8 @@ Im MVP ist der Space-Kontext im Dialogzug die letzten gespeicherten Journal Entr
Fachliche Inhaltsquelle der Journalgenerierung sind die ausdrücklich ausgewählten Conversations des Journal Day. Stufe 2 erhält ausschließlich das lokal erzeugte Verified Artifact. Frühere Space Entries sind keine zusätzliche Faktenquelle in Generate. Das Writing Profile darf nur die Form beeinflussen. Space-Kontext kann die Dialogführung stützen, wird aber nicht still zur Journalquelle.
**Additiv 2026-08-29:** Ob Core, Facet, Traits und Style Examples überhaupt in den Prompt gelangen, bestimmt die gewählte Stilanwendungsausprägung (`writing_profile_and_journaling.md`). `Ohne persönliches Profil` ist eine profilfreie Kontrollgruppe. Der aktuelle Tagesdialog bleibt Inhaltsquelle, nicht Stilvorlage.
---
## 8.3 Generierungsprinzip
@ -557,6 +563,8 @@ Der Current Brief wird lokal zusammengesetzt, ohne zusätzlichen Modell-Call:
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.
**Additiv 2026-08-29:** Die Stilanwendungsausprägung filtert, welche dieser lokal zusammengesetzten Stilquellen den Provider überhaupt erreichen. Ohne Freigabe entfallen Core, Facet, Traits und Beispiele vollständig, einschließlich Formhinweisen aus dem Tagesdialog. Das ändert die Quellenpriorität nicht; es begrenzt den Egress.
---
# 10. Journal Editor

View File

@ -43,7 +43,7 @@ Nicht durch Fake-Provider bewiesen:
Kein Codefehler des Freeze Candidate, sondern Betriebsreife:
- PostgreSQL, Docker/Compose, TLS, Reverse Proxy
- PostgreSQL, Docker/Compose, TLS, Reverse Proxy**nach** SQLite-Restore der Urlaubsdaten (`../technical/environment_handover.md`, `../technical/runtime_and_deploy.md` §7.2)
- Gitea-Pipelines / CI
- Ollama bzw. lokales Detect-Modell
- Verschlüsselung at rest, DSFA, persistentes Audit ohne Prompts

View File

@ -439,10 +439,10 @@ Foundation-Verletzungen, die ein Refactor erzwingen würden, sind **nicht** gefu
| 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. |
| Bekannter Tagesplan (Cres-Beispiel) | **Teilweise / Ungenauigkeit** | **Opening (seit 2026-09-03):** nur user-Dialog des vorigen Kalendertags, nicht gespeicherte Entries. **Laufender Dialogzug:** Recency-Ausschnitte (Entries und jüngere Original-Conversations) bleiben Hinweis. Kein Plan-Objekt, keine Vor/Nach-Logik über den Folgetag hinaus, 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“ | **Erfüllt** als begrenzte Keimzelle | Erster Impuls aus belegtem Plan oder offenem Tagesfaden; sonst neutral. Keine Pattern-Engine. |
| Kontextsensitiver Einstieg statt „Erzähl von deinem Tag“ | **Erfüllt** als begrenzte Keimzelle | Erster Impuls aus belegtem *gestrigem* user-Plan oder offenem Tagesfaden; sonst neutral. Entries und Prefix-Recency sind kein Einstieg. Keine Pattern-Engine. |
| 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. |
@ -472,6 +472,10 @@ Foundation-Verletzungen, die ein Refactor erzwingen würden, sind **nicht** gefu
| 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** | |
**Additiv 2026-08-29:** Die Stilanwendung filtert Writing-Profile-Core, Facet, Traits und Style Examples je benannter Voice-Ausprägung. `Ohne persönliches Profil` ist eine profilfreie Kontrollgruppe. Space-Kontext und faktisches Langzeitgedächtnis bleiben keine Stilquellen.
**Additiv 2026-08-29:** Vergleichsausprägungen `Legacy vor Stilkontextsteuerung` rekonstruiert die frühere volle Stilkontextsemantik unter neuen IDs. Vorhandene Voice-IDs bleiben unverändert.
## 5.7 Editor, Versionen, Nachschlagen, Screens
| Anforderung | Stand | Befund |
@ -514,7 +518,7 @@ Foundation-Verletzungen, die ein Refactor erzwingen würden, sind **nicht** gefu
1. **`mvp.md` §1 und §5.1** versprechen „relevante frühere Aussagen“ und „wiederkehrende Aktivitäten“. Der Code liefert Recency-Ausschnitte finaler Entries plus begrenzte Original-Ausschnitte jüngerer Space-Conversations. Der klarstellende Absatz in §5.1 und §8.2 ist der gültige Implementierungsstand; die Beispiele §5.45.5 und Case C bleiben **schärfer als der Code** (kein Plan-Objekt, keine Wiederholungszählung).
2. **`mvp.md` §17** sprach vom „nächsten Schritt: technischen Plan ableiten“. Der Slice ist gebaut. Dieses Dokument ist der Abgleich; die Formulierung in §17 bleibt historisch.
3. **`mvp.md` §4.1 Konsolidierung** beschreibt narrative Kriterien. Der Slice nutzt lokale Signale derselben Dimensionen, keine LLM-Typklassifikation.
4. **`mvp.md` §6 Einstieg** verlangt einen kontextsensitiven ersten Impuls. Seit 2026-08-25: journalspezifischer Opening-Adapter; ohne belegten Plan oder offenen Tagesfaden lokal neutral.
4. **`mvp.md` §6 Einstieg** verlangt einen kontextsensitiven ersten Impuls. Seit 2026-08-25: journalspezifischer Opening-Adapter; ohne belegten Plan oder offenen Tagesfaden lokal neutral. **Additiv 2026-09-03/07:** Plan nur noch aus dem user-Dialog des unmittelbar vorigen Kalendertags, nicht aus Journal-Entries oder älterer Recency. Opening-Call ohne Space-Recency. Ein 400-Zeichen-Entry-Prefix (typisch: nachgetragener Morgenanker ohne späteren Vollzug) ist kein Opening-Mandat. Der laufende Dialogzug lädt Recency-Ausschnitte weiterhin; das ist kein Slice-Gap des Openings, sondern bewusst offene Heuristik bis zu Relevanz/Memory.
5. **`mvp.md` §8.2 Punkt 2** Space-Kontext bei der Generierung: Generate sieht Prior-Entries nicht. Für diesen Freeze bewusst so begrenzt (`mvp.md` §8.2.1).
6. **`mvp.md` §9.3** listet Lernsignale, von denen nur ein Teil heuristisch existiert.
7. **`mvp.md` §11.3 / §13.113.2** Papierkorb-UI und Entry-Liste sind vorhanden. Dialog-Medien bleiben bewusst aus (`mvp.md` §10.2).
@ -545,6 +549,7 @@ Zielmodell und Non-Goals, nicht „vergessen im MVP“:
- Contextual Continuation als Ziel-IA (der Freeze hat nur eine journalspezifische Startkeimzelle)
- Browser-Zurück als harter Unsaved-Blocker (Data-Router)
- Dialogmedien als direkte Dialogquelle
- Prefix-Recency (400 Zeichen) als Hinweis im *laufenden* Dialogzug; Relevanz statt Prefix bleibt Zielmodell (`resurfacing_and_saturation.md`)
---
@ -554,7 +559,7 @@ Keine neue Horizontal-Konzeption. Getrennt halten, sonst baut ein Agent das Targ
## 8.1 Distanz zum **freigegebenen MVP-Text** (`mvp.md`)
1. **Case C Wiederholung** wiederkehrende Aktivität bleibt Recency, keine Zählung. Der erste Impuls greift nur belegte Pläne oder offene Tagesfäden auf.
1. **Case C Wiederholung** wiederkehrende Aktivität bleibt Recency, keine Zählung. Der erste Impuls greift nur belegte Pläne aus dem user-Dialog von gestern oder offene Tagesfäden desselben Journal Day auf; Journal-Entries nicht.
2. **Generate und Space-Ausschnitte** für diesen Freeze entschieden: Day-Conversations plus Verified Artifact, keine Prior-Entries als Generate-Fakten (`mvp.md` §8.2.1).
3. **Dialog-Medien** bewusst gestrichen für den Slice (`mvp.md` §10.2); Editor-Medien bleiben.
4. **Lokales Detect-Modell** Klartext an externe Detect-Provider ist in Production blockiert; Ollama als Ablösung der Dev/Test-Phase bleibt offen.
@ -593,6 +598,7 @@ Ein Agent, der 8.2 in diesem Slice „schließt“, verletzt Foundation-Regeln 2
|---|---|
| `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/journal_opening.py` | Erster Journal-Impuls; Plan nur gestriger user-Dialog; Opening ohne Space-Recency |
| `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 |
@ -614,6 +620,7 @@ Ein Agent, der 8.2 in diesem Slice „schließt“, verletzt Foundation-Regeln 2
| `backend/tests/test_journal_shape.py` | Keine Pronomenersetzung nach Demask; akzeptierter Wortlaut bleibt | Modellqualität, Stil |
| `backend/tests/test_writing_profile.py` | Quellenpriorität | „klingt wie die Person“ |
| `backend/tests/test_frame.py` | Auth, Prompts, Rahmen | Journal-IA |
| `backend/tests/test_journal_opening.py` | Neutral, Folgetag-Plan, abgeschlossener Entry kein Mandat, Plan älter als gestern entfällt | Opening-Qualität des Modells |
| `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.

View File

@ -132,6 +132,10 @@ Persistenz ist profilbezogen (`journal_generation_settings`). Ein Request-Snapsh
**Additiv 2026-08-28 (akzeptierter Modelltext und wirksame Auswahl):** Nach Demaskierung darf der übernommene Journaltext lokal nicht mehr grammatisch oder referenziell umgeschrieben werden. Das Qualitätsziel, Namenswiederholungen zu vermeiden, bleibt beim Modell; der MVP erzwingt es nicht durch eine lokale Heuristik. Preview und Generate lösen dieselbe `generation_selection` (ID und Revision) über `compile_selection`. Eine ungültige oder nicht mehr aktive Auswahl fällt nicht still auf einen Default. Admin-Publish, Aktivieren oder Default-Setzen setzt eine bestehende Nutzerauswahl nicht zurück.
**Additiv 2026-08-29 (Stilkontext der Stilanwendungsvariante):** Die Dimension Persönliche Stimme bleibt eine einzige Auswahl. Zu jeder Stilanwendungsausprägung gehört neben der Promptanweisung eine strukturierte Stilkontext-Konfiguration (`include_core`, `include_facet`, `include_traits`, `include_style_examples`), keine Prozentgewichtung und kein zusätzlicher sichtbarer Regler. Der Prompt Builder überträgt nur freigegebene Stilbestandteile; leere Profilabschnitte entfallen. `Ohne persönliches Profil` sendet keinen Writing-Profile-Inhalt, keine Style Examples und keine Stilableitung aus dem aktuellen Tagesdialog. Historische Beispiele bleiben `style only`. Promptanweisung und Stilkontext werden gemeinsam versioniert. Unveränderte Systemseeds werden idempotent aktualisiert; unabhängig erzeugte Klone und gespeicherte Auswahlen bleiben gültig. Faktisches Langzeitgedächtnis und Space-Kontext sind keine Stilquellen dieses Schritts.
**Additiv 2026-08-29 (Legacy-Vergleich und immutable Seeds):** Die aktuellen Voice-IDs und gespeicherten Auswahlen bleiben. Zusätzlich gibt es rekonstruierte Vergleichsausprägungen `Legacy vor Stilkontextsteuerung` mit neuen IDs, der früheren Promptanweisung und dem früher tatsächlich verwendeten vollen Stilkontext (Core, Facet, Traits, Style Examples). Ab diesem Stand darf ein Seed eine vorhandene Varianten-ID fachlich nicht mehr überschreiben; eine semantische Änderung legt eine Nachfolge-ID mit Vorgängerbezug an. Neue Defaults dürfen auf die Nachfolge zeigen. Archivierte Varianten und Admin-Klone bleiben unangetastet. Ohne Style Examples enthält der gerenderte Prompt keinen verwaisten `STYLE_EXAMPLES`-Verweis.
---
## Journal Entry als häufiger, aber nicht zwingender Abschluss einer Tagesreflexion

View File

@ -65,10 +65,18 @@ Nicht übernehmen: Mitai-Admin für Körpertarife, Coupons, Training Types als K
**Additiv 2026-08-28 (Detect-Diagnose und gespeicherter Entwurf):** Die Testspur trennt technische Chunk-Verarbeitung, Anwendung der bestätigten Registry, semantische Treffer des aktuellen Requests, Detect-Modell und semantische Unsicherheit. `full_detection_coverage` darf nicht als vollständige Identitätserkennung gelesen werden. Nach Übernahme zeigt die Spur den gespeicherten Entwurf (`stored_title` / `stored_body` / `reply`); API, Draft und Trace müssen denselben Wortlaut haben. `generation_selection` zeigt die tatsächlich verwendete ID und Revision, nicht eine visuell benachbarte Ausprägung.
**Additiv 2026-08-29 (wirksame Stilanwendung):** Die Testspur zeigt `style_application` mit ID, Label, Revision, angeforderter Freigabe und tatsächlich gesendeten Stilblöcken (Core/Facet/Traits/Beispiele, Zeichen und geschätzte Tokens) sowie Auslassungsgründen inklusive Budget. Das beschreibt die Provider-Eingabe, nicht nur die Katalogkonfiguration.
**Additiv 2026-08-29:** `style_application` und der Snapshot nennen bei Nachfolgevarianten den Vorgänger (`cloned_from`).
## 4.3 Implementierungsstand (persistierte Debug-Testspur)
**Additiv 2026-08-28:** Die Live-Testspur unter dem Dialog ist entfernt. Persistenz unter `/admin/debug` (`GET/PUT /api/admin/debug`). Default aus. Ist der Schalter an, wird jeder Admin-Schritt (Dialogzug inkl. Opening, Journalentwurf, Profilreview) in `debug_runs` gespeichert und **Space / Tag / Gespräch / Schritt** zugeordnet (`GET /api/admin/debug/tree`). Mapping-Tabelle, `local_label` und Secrets gehören nicht in die Payload. Nicht-Admin-Profile werden nicht aufgezeichnet. Im Dialog selbst: Download der Gesprächs-JSON, solange Persistenz an ist (`GET /api/admin/debug/export?conversation_id=`). Am generierten Tagebucheintrag bleibt die Journal-Testspur sichtbar; Download `GET /api/admin/debug/export?purpose=journal_generate&journal_day_id=`, Nachladen `GET /api/admin/debug/latest`. Admin-Seite: Baum, Einzelschritt-Detail, Export JSON/Markdown. `kansho.debug_export` v2. Kein Gateway-Bypass, kein Provider-Upload, keine globale In-Memory-`debug_history`.
**Additiv 2026-08-29:** Persistierte Engine-Fehler führen `cost_report` (Detect-/Generate-Kosten, `billed`). Die Nutzer-Settings zeigen denselben Kostenstand bei fehlgeschlagener Profilanalyse; die Admin-Spur bleibt die Stelle für Compact-Trace ohne Mapping.
**Additiv 2026-08-29 (Detect-Abbruch der Journalgenerierung):** Ein vor dem Narrationsmodell abgebrochener Journal-Lauf wird bei aktiver Persistenz genau einmal als Fehlerlauf gespeichert (Purpose, Journal-Tag, Generierungsauswahl/Stilanwendung, Abbruchstufe, fachlicher Code, `generate_called: false`, Detect-Versuche). `debug_store.sanitize` bleibt bei zyklischen Containern begrenzt. Ein Persistenzfehler ersetzt den ursprünglichen `EngineError` nicht; die UI erhält den Detect-/Privacy-Code, nicht HTTP 500. Tests: `backend/tests/test_detect_contract_retry.py`.
## 5. Entscheidungsstand
| Thema | Stand | Status |

View File

@ -100,6 +100,8 @@ Produkt-Endpunkte hinter Session-Auth, Isolation über `profile_id`:
- Generation Policy: `GET /generation-settings` — profilbezogene Defaults der vier Journal-Ausgabeeinstellungen, nicht Writing Profile. Die Anweisungstexte kommen aus `generation_instruction_fragments`, nicht aus dem Nutzerregler.
- **Additiv 2026-08-27:** Generate sendet `generation_selection` (`transformation_id`, `detail_id`, `voice_id`, `narrative_id`) und `remember_generation_selection` (Default false). `GET /generation-settings` liefert `selection`, aktive `options` ohne Promptanweisung und System-`defaults`. Unbekannte oder nicht aktive IDs: 400 `invalid_generation_selection`. Der gespeicherte Entwurf enthält `generation_snapshot` und `generation_summary`.
- **Additiv 2026-08-28:** Ungültige `generation_selection` bricht vor Detect- und Generate-Provideraufrufen ab. Preview nutzt denselben `compile_selection`-Pfad. Katalog-Publish/Default ändert gespeicherte Profilauswahl nicht.
- **Additiv 2026-08-29:** Voice-Ausprägungen tragen `style_context` (`include_core`, `include_facet`, `include_traits`, `include_style_examples`). `GET /generation-settings` liefert diese Flags an den Journal-Tag, ohne Promptanweisung. Admin `PUT/POST` versioniert Anweisung und Stilkontext gemeinsam. Kein zusätzlicher Generate-Parameter.
- **Additiv 2026-08-29:** Snapshot und Trace führen `cloned_from` der Voice-Ausprägung. Legacy-Vergleichs-IDs sind normale aktive Optionen, nicht Default.
## 5.3 Implementierungsstand (Generierungsrichtlinien)
@ -127,7 +129,7 @@ UI: `/admin/generation` mit getrennten Dimensionen, Karten und einem Editor nach
- Entries: `POST /entries`, `GET /entries/{id}`, Versionen, Versions-Restore, Soft-Delete, `POST /entries/{id}/undelete`, `POST /entries/{id}/purge`. Speichern aus einem Entwurf mit `entry_id` + `origin=accepted_draft` legt eine neue Version desselben Entry an.
- Media: Upload/GET/DELETE; Bilder und einzelne Videos; Position/Unterschrift im Entry-Body als Markdown `![…](kansho-media:<id>)`
- Tagesstichpunkte: `PATCH /days/{id}/scratch` — lokal, nicht Context-Builder
- Writing Profile: `GET /writing-profile`, Import (optional `occurred_at`, `context_hint`), Korpus `GET/POST /writing-profile/corpus`, Rebuild, `PATCH` Governance, Facet-Edit/Lock, Trait-Edit `PATCH /writing-profile/traits/{slug}`, Vorschläge annehmen/verwerfen. Brief ist eine abgeleitete Sicht aus Traits und zeitgestempelten Quellen. Media-Token werden vor dem Brief entfernt. JSON-Export/Restore: `GET /writing-profile/export`, `POST /writing-profile/restore`. Review: `GET /writing-profile/review`, `POST /writing-profile/review/paste`, `POST /writing-profile/review/api`, `POST /writing-profile/review/import`, `POST /writing-profile/review/accept`, `POST /writing-profile/review/reject`. Initial Build: `POST /writing-profile/initial-build/paste`, `POST /writing-profile/initial-build/api`. API und Copy/Paste nutzen denselben Vertrag `kansho.profile_analysis_package` / `kansho.profile_analysis_result` (`mode` `initial_build`\|`review`, `corpus`, `existing_before_new`, `expected_result`). Legacy-`kind` `kansho.profile_review_*` bleibt lesbar. Import erzeugt ein Proposal, kein Current Profile; Übernahme erst nach Accept. Der Current Brief ist kein Import-/Exportformat.
- 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`, `POST /writing-profile/review/accept`, `POST /writing-profile/review/reject`. Initial Build: `POST /writing-profile/initial-build/paste`, `POST /writing-profile/initial-build/api`. API und Copy/Paste nutzen denselben Vertrag `kansho.profile_analysis_package` / `kansho.profile_analysis_result` (`mode` `initial_build`\|`review`, `corpus`, `existing_before_new`, `expected_result`). Legacy-`kind` `kansho.profile_review_*` bleibt lesbar. Import erzeugt ein Proposal, kein Current Profile; Übernahme erst nach Accept. Der Current Brief ist kein Import-/Exportformat. **Additiv 2026-08-29:** Fehler von Review/Initial-Build-API tragen `cost_report` und den Providergrund (`code`, `message`, optional `http_status` / `provider_message`); die generische Client-Meldung „Anfrage fehlgeschlagen“ ist nicht mehr der einzige sichtbare Text.
- 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`). Journal-Backup ist `local_backup.py`, nicht dieser Profil-Export.

View File

@ -2,7 +2,7 @@
title: "Kanshō Technischer Dokumentationsindex und Context Bundles"
version: "0.1"
status: "Arbeitsstand"
date: "2026-08-19"
date: "2026-09-07"
product_family: "Jinkendo"
document_role: "Documentation Index / Technical Context Loading Guide"
parent_document: "technische_zielarchitektur.md"
@ -33,7 +33,8 @@ Fachlich zusätzlich immer bei Querschnittsentscheidungen:
| `auth_identity_and_roles.md` | Sessions, Rollen, Admin-Gate |
| `frontend_pwa_shell.md` | PWA, Responsive Shell, Navigation |
| `backend_and_api.md` | Router, API-First |
| `runtime_and_deploy.md` | Migrationen, Compose, Gitea |
| `runtime_and_deploy.md` | Migrationen, Compose, Gitea; Transfer Laptop → Heim §7.2 |
| `environment_handover.md` | **Session-Handover Umgebungswechsel** (SQLite-Ist, Backup, zwei Wellen, offene Host-Fragen) |
| `platform_extensibility.md` | Registry, Prompt-Engine, Konfiguration |
| `data_architecture.md` | Persistenzgrenzen, Entitäten |
| `memory_storage_and_offline.md` | Dialogspeicher, Sync, Offline |
@ -126,6 +127,18 @@ Kanonisches Home **wie** der Slice gebaut ist: `mvp_implementation.md`. Fachlich
4. `privacy_gateway.md` (eigener Egress-Typ)
5. fachlich: `../functional/produktvision_und_produktidentitaet.md` §19.419.5
### Umgebungswechsel Laptop → Heim / Server
1. `environment_handover.md`
2. `runtime_and_deploy.md`
3. `product_frame_and_stack.md`
4. `mvp_implementation.md` §1 und §4
5. `../../work_orders/laptop_closeout.md`
6. `../../work_orders/home_environment_setup.md`
7. fachlich: `../functional/guardrails.md` (Datenklassen beim Kopieren von Backup und `.env`)
Arbeitsaufträge liegen unter `docs/work_orders/`. Sie ersetzen keine kanonischen Kapitel.
## 4. Ladeprinzip
> Root-Dokumente plus 24 betroffene Kapitel. Nicht den ganzen technischen und fachlichen Ordner gleichzeitig laden.
@ -134,5 +147,5 @@ Vor Vereinfachungen die Invariantenliste in `technische_zielarchitektur.md` §3.
## 5. Code-Gerüst
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_reconstruct`, `mvp.journal_generate`, `mvp.entity_detect`, `mvp.profile_review`. Persönliche Folgestufen konsumieren ein lokal erzeugtes `VerifiedArtifact` (`provenance_verification.md`); der Journal-Adapter setzt die Policy. Journal-Generate: Stufe 1 lokal (`local_source_artifact`), Stufe 2 ein Narrations-Call (Faktentreue, eigenständige Journalprosa, nicht Wortlautkopie). Journalspezifische Ausgabeeinstellungen lokal kompiliert (`journal_generation_policy.py`); Anweisungstexte in `generation_instruction_fragments`, nicht im Code; das ist keine Modelltemperatur. Opt-in-Vergleich: `backend/journal_eval.py` (nicht Produktionslauf; Live nur explizit über das Privacy Gateway). Request-scoped Maskierungsmanifest und Pre-Egress: `privacy_gateway.md` §9.4. Semantische Request-Detection und bestätigte Registry: `privacy_gateway.md` §9.5. 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). **Additiv 2026-08-28:** opt-in lokale Persistenz unter `/admin/debug` (Default aus, kein Mapping). 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.
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_reconstruct`, `mvp.journal_generate`, `mvp.entity_detect`, `mvp.profile_review`. Persönliche Folgestufen konsumieren ein lokal erzeugtes `VerifiedArtifact` (`provenance_verification.md`); der Journal-Adapter setzt die Policy. Journal-Generate: Stufe 1 lokal (`local_source_artifact`), Stufe 2 ein Narrations-Call (Faktentreue, eigenständige Journalprosa, nicht Wortlautkopie). Journalspezifische Ausgabeeinstellungen lokal kompiliert (`journal_generation_policy.py`); Anweisungstexte in `generation_instruction_fragments`, nicht im Code; das ist keine Modelltemperatur. **Additiv 2026-08-29:** Voice-Ausprägungen tragen `style_context_json` (Freigabe von Core/Facet/Traits/Style Examples). Opt-in-Vergleich: `backend/journal_eval.py` (nicht Produktionslauf; Live nur explizit über das Privacy Gateway). Request-scoped Maskierungsmanifest und Pre-Egress: `privacy_gateway.md` §9.4. Semantische Request-Detection und bestätigte Registry: `privacy_gateway.md` §9.5. 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). **Additiv 2026-08-28:** opt-in lokale Persistenz unter `/admin/debug` (Default aus, kein Mapping). 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. **Additiv 2026-09-07:** Umgebungswechsel der Urlaubsinstanz: `environment_handover.md`.

View File

@ -0,0 +1,207 @@
---
title: "Kanshō Handover Laptop-Instanz → Heim-Entwicklung und Server"
status: "Aktiver Übergabestand"
date: "2026-09-07"
product_family: "Jinkendo"
document_role: "Session Handover / Environment Transfer / Implementation Bootstrap"
parent_document: "runtime_and_deploy.md"
canonical_concept_handover: "docs/architecture/functional/handover.md"
---
# Kanshō Handover: Laptop-Urlaubsinstanz → normale Entwicklung und Server
Dieses Dokument ist der **technische Aufsatzpunkt** für den Umgebungswechsel. Es ersetzt weder `runtime_and_deploy.md` noch das fachliche Konzept-Handover `../functional/handover.md`.
**Stand:** 2026-09-07, erstellt auf dem Urlaubs-Laptop unter `C:\dev\Kansho`.
Ziel: Code, Dokumentation und persönliche Testdaten so übergeben, dass zu Hause weiterentwickelt und später auf Docker/PostgreSQL umgestellt werden kann, ohne die Urlaubsdaten zu verlieren.
---
## 1. Leitentscheidung für den Transfer
**Zwei Wellen, nicht eine.**
| Welle | Was | Warum |
|---|---|---|
| **1. Kontinuität** | Gitea-Stand klonen, lokale SQLite-Instanz wie bisher, Backup wiederherstellen, Dialog-/Journalqualität prüfen | Der gebaute Slice ist SQLite. Die persönlichen Daten liegen in `backend/data/`. Postgres ist Ziel, nicht Ist. |
| **2. Betriebsrahmen** | Docker Compose, eigenes Postgres, Deploy-Muster analog Mitai, Gitea-Runner | Entschiedenes Ziel in `product_frame_and_stack.md`. Nicht im selben Schritt wie der erste Daten-Restore. |
Ein SQLite-Restore auf dem Heimrechner **vor** der Postgres-Migration ist die Restore-Übung, die `runtime_and_deploy.md` §7 Punkt 3 verlangt. Erst wenn Welle 1 grün ist, wird der Slice auf Compose/Postgres gehoben.
Nicht tun: Urlaubs-SQLite in ein noch nicht existierendes Postgres-Schema laden, während uncommitteter Code nur auf dem Laptop liegt.
---
## 2. Ist-Stand der Laptop-Instanz (2026-09-07)
### 2.1 Laufzeit
| Element | Ist |
|---|---|
| Host | Windows-Laptop, Pfad `C:\dev\Kansho` |
| Start | ohne Docker: Uvicorn **8018**, Vite **5188** (`strictPort`) |
| Persistenz | SQLite `backend/data/kansho.sqlite` (~11,3 MB) |
| Medien | `backend/data/media/<profile_id>/` (70 Dateien, ~8,9 MB Nutzlast; zwei große JPEGs, viele Mini-WebM/PNG) |
| Secrets | `backend/.env` (nicht im Git, nicht im Backup-Archiv) |
| Python | 3.12, venv `backend/.venv` |
| Remote | `https://gitea.stommer.de/Lars/Kansho.git` |
### 2.2 Git
| Element | Ist |
|---|---|
| Branch | `main` |
| HEAD | `main` auf dem Laptop, nach `e42f751 MVP 1.0`: Opening-Fix `ec0f8ba`, danach Transfer-Doku |
| Remote | Push nach Gitea ist die letzte Laptop-Pflicht; ohne Push sieht der Heim-Clone nur `MVP 1.0` |
| `develop` | existiert **nicht** (Familienmuster Dev=`develop` / Prod=`main` ist entschieden, aber noch nicht angelegt) |
Committet auf dem Laptop (2026-09-07):
- `ec0f8ba` Opening-Fix: Vorhaben nur aus dem **user-Dialog des vorigen Kalendertags**; Journal-Entries kein Opening-Mandat; Opening-Call ohne Space-Recency. Fehlerbild (Nachsatz + 400-Zeichen-Prefix) in `mvp.md` §6.1 und Fit-Gap.
- Transfer-Doku, Work Orders, Slice-2-Brief auf `main` direkt danach.
Bewusst **nicht** geschlossen: Prefix-Recency im laufenden `dialogue_turn`.
Unversioniert vor diesen Commits, jetzt im Tree: Work Orders, `environment_handover.md`, Slice-2-Brief `Idea_seconmd_sclide.md`.
Nicht in Git und **nicht** ins Backup: `backend/.env`.
### 2.3 Persönlicher Datenbestand (Inventar, kein Inhalt)
Ein Admin-Profil seit 2026-08-19. Zwei Spaces: `Kroatien 2026` (Urlaubsjournal) und `Test für Prosagenerierung`. 17 Journal Days, 17 Current-Entries, 20 Conversations, 379 Messages, Writing Profile vorhanden, Identity-Mappings lokal, Debug-Runs opt-in persistiert.
Das ist **Klasse-A/B-Material**. Es darf nicht nach Gitea, nicht an Provider, nicht unverschlüsselt in die Cloud.
### 2.4 Backup dieser Session
Erzeugt: `local-backups/kansho-20260907-055703.zip` (gitignoriert).
Enthält SQLite (Backup-API) + 70 Mediendateien + Manifest/Checksummen. Enthält **keine** `.env` und keine Keys. Das Archiv ist **unverschlüsselt** und persönlich.
Vor dem Abschalten des Laptops: Archiv auf verschlüsselten USB-Stick, NAS-Share oder den Heimrechner kopieren. Zweite Kopie getrennt halten. Nicht committen.
---
## 3. Was auf dem Laptop noch zu tun ist
Siehe Arbeitsauftrag `../../work_orders/laptop_closeout.md`.
Kurz:
1. Opening-Fix (`ec0f8ba`) und Transfer-Doku (`29e3d0f`) nach Gitea pushen, falls `origin/main` noch hinterherhinkt.
2. Backup-Archiv und `.env` **getrennt** physisch mitnehmen.
3. Backend beenden, keine weiteren Dialoge mehr als „führende Instanz“ führen, sobald Welle 1 zu Hause bestätigt ist.
4. Slice-2-Konzept, Debug-Export und Memory-Slice **nicht** auf dem Laptop zu Ende bauen.
---
## 4. Offene Cursor-Sessions (Laptop)
Diese Sessions sind Kontext, keine zweite Source of Truth. Kanonisch bleiben die Repository-Dateien.
| Session | Thema | Status für den Transfer |
|---|---|---|
| [Opening-Impuls](d5e159cf-4d3c-4069-95f9-123a5500b1da) | Erster Impuls zog abgeschlossene Vortage / 400-Zeichen-Entry-Prefix | **Code und kanonische Doku fertig (2026-09-07).** Invariante in `mvp.md` §6.1, Fit-Gap §6.4, `mvp_implementation.md` §17. Bewusst offen: Prefix-Recency im laufenden Dialogzug. Persönliche Dialoginhalte nicht in Doku. |
| Diese Session | Umgebungswechsel | **Laptop-pflichtig.** Handover und Work Orders angelegt; kanonische Opening-Doku nachgezogen. Commit/Push und physisches Backup bleiben die Abnahme. |
| `docs/work_orders/debug_diagnostics_perfection.md` | Zentraler Admin-Export | **Verschieben.** Kein Laptop-Zwang. |
| `docs/work_orders/dialogue_memory_next_slice_handover.md` | Dialog + Langzeitgedächtnis | **Verschieben.** Konzept/Fit-Gap, keine Implementierung auf dem Laptop. |
| `docs/architecture/functional/Idea_seconmd_sclide.md` | Fachlicher Slice-2-Brief | **Verschieben.** Keine Dateien ändern, bis eine Konzept-Session ihn abarbeitet. |
| Ältere MVP-Sessions (Freeze, Editorial, Policy, Debug-Persistenz, Writing Style) | Journal-MVP 1.0 | **Abgeschlossen** im Commit `e42f751`, sofern nach Gitea gepusht. |
Ältere Transcripts lokal unter Cursor; sie müssen nicht migriert werden, wenn Gitea den Code-Stand trägt.
---
## 5. Welle 1 Heim-Entwicklung mit SQLite
Auftrag: `../../work_orders/home_environment_setup.md` Phase A.
1. Auf dem Heimrechner (erwarteter Pfad analog `C:\dev\Kansho` oder später Linux-Checkout) `git clone https://gitea.stommer.de/Lars/Kansho.git`.
2. `.\scripts\dev-setup.ps1` (Windows) bzw. venv + `npm install`.
3. `backend/.env` aus der **separaten** Secret-Kopie anlegen, nicht aus dem Backup.
4. Backend **nicht** mit Produktionsdaten starten, bevor das Restore-Ziel leer oder bewusst überschrieben wird.
5. Restore: `.\scripts\backup-local.ps1 restore -Archive <pfad>\kansho-20260907-055703.zip -Confirm -Replace`.
6. Start: Backend 8018, Frontend 5188. Login mit dem bestehenden Admin-Konto (Passwort-Hash steckt in der SQLite, nicht in `.env`).
7. Smoke: Space `Kroatien 2026` sichtbar, Journal Days vorhanden, ein Entry mit Medien, ein Dialogzug fail-closed ohne Key und mit Key über Gateway, Health `GET /api/health`.
8. `.\scripts\test-mvp.ps1` (isolierte Temp-DB, ändert die Restoredaten nicht).
Erst wenn das gilt, ist der Laptop nicht mehr führend.
---
## 6. Welle 2 Docker, Postgres, Server
Auftrag: `../../work_orders/home_environment_setup.md` Phase BC.
Mitai-Referenz auf dem Heimrechner: `C:\dev\mitai` (Compose, `.gitea/workflows/`, Hostpfade `/home/lars/docker/bodytrack` und `bodytrack-dev`).
**Noch nicht im Kanshō-Repo:** Dockerfiles, Compose, Gitea-Workflows, Postgres-Adapter. `backend/db.py` ist SQLite (`sqlite3`, `PRAGMA`, `datetime('now')`). Das ist eine eigene Implementierungsarbeit, kein Copy-Paste von Mitai-Fachlogik.
Entschieden (nicht neu verhandeln):
- Docker Compose: Frontend, Backend, PostgreSQL 16
- Branches: `develop` → Dev, `main` → Prod
- Secrets nur in Server-`.env`
- Persönlicher LLM-Egress nur über Privacy Gateway
- Kein Auto-Rollback der Datenbank
- Kanshō-Ports **nicht** Mitai `3002`/`8002`/`3099`/`8099` kopieren
Offen, vor Compose festlegen:
1. Läuft Kanshō auf demselben Pi wie Mitai, als **eigenes** Compose-Projekt?
2. Eigenes Postgres (bevorzugte Richtung: ja, eigene Instanz/DB `kansho`, kein Shared-Schema mit Mitai)?
3. Dev-Domain / Prod-Domain / Host-Pfade (Hypothese: `dev.kansho.jinkendo.de` / `kansho.jinkendo.de`)?
4. Dev-Ports auf dem Pi, lokal bleiben 5188/8018 für den Windows-Checkout ohne Docker.
5. Ob Welle 2 zuerst Compose **mit SQLite-Volume** als Zwischenstand nutzt oder direkt Postgres + Migrationsadapter.
Empfohlene Reihenfolge in Welle 2:
1. Offene Host-Fragen beantworten und in `runtime_and_deploy.md` §1/§6 eintragen.
2. `develop` anlegen, sobald Auto-Deploy gewünscht ist; bis dahin darf `main` der einzige Branch bleiben.
3. Compose-Gerüst analog Mitai, **eigene** Container-/Volumen-/Projektnamen (`kansho-*`, keine `bodytrack/`-Pfade).
4. Backend-Start: Postgres ready → nummerierte SQL-Migrationen → App. Fail-fast.
5. Datenübernahme SQLite → Postgres als **eigenes** Migrationsskript mit Probe-Restore, nicht still im Alltagspfad.
6. Gitea-Workflows erst, wenn Runner-Pfade existieren (`runtime_and_deploy.md` §4).
7. Watchtower später prüfen, nicht in diesem Transfer.
---
## 7. Datenschutz beim Transfer
- Backup und `.env` nie in Git, nie in Chat-Uploads, nie an OpenRouter.
- Identity-Mappings bleiben lokal (Klasse A).
- Das Zip ist Klartext-Journal. Transport verschlüsseln (BitLocker-Stick, verschlüsseltes NAS, `age`/`gpg` nach Wahl).
- Nach erfolgreichem Heim-Restore Laptop-Kopie als Archive behalten, nicht parallel weiterschreiben.
- Debug-Traces in der DB können Prompt-Ausschnitte enthalten; sie reisen im Backup mit. Auf dem Server den Schalter `debug.persist_traces` prüfen.
---
## 8. Bewusst nicht Teil dieses Transfers
- Slice 2 / Memory-Continuity implementieren
- Zentralen Debug-Export umbauen
- Voice, semantisches Retrieval, Self Model
- Mitai-Verzeichnisnamen, Mitai-Ports, Shared-Postgres-Schema
- Fachliche Produktentscheidungen aus dem Konzept-Handover
---
## 9. Nächster Arbeitsschritt
1. Auf dem Laptop: `laptop_closeout.md` abschließen (Commit/Push, Backup kopieren).
2. Zu Hause: neue Session mit diesem Dokument plus `runtime_and_deploy.md` und `home_environment_setup.md`.
3. Welle 1 bis Restore-Abnahme.
4. Erst dann Welle 2.
---
## 10. Querverweise
- Runtime: `runtime_and_deploy.md`
- Stack: `product_frame_and_stack.md`
- SQLite-Ist: `mvp_implementation.md` §1 und §4
- Fachliches Session-Handover: `../functional/handover.md`
- Laptop-Abschluss: `../../work_orders/laptop_closeout.md`
- Heim-Setup: `../../work_orders/home_environment_setup.md`
- Mitai: `C:\dev\mitai\docker-compose.yml`, `docker-compose.dev.yml`, `.gitea/workflows/`

View File

@ -93,6 +93,12 @@ Admin sieht unter dem Gespräch **keine** Live-Testspur mehr.
**Additiv 2026-08-28:** `/admin/debug` schaltet eine lokale Persistenz ein und aus (Default aus) und zeigt den Verlauf als Space / Tag / Gespräch / Schritt. Ist Persistenz an, bietet der Dialog (Journal-Tag und Dev-Harness) den Download der Gesprächs-JSON. Der generierte Tagebucheintrag behält die Journal-Testspur (CallTrace) und den JSON-Download des Entwurfs.
**Additiv 2026-08-29:** Der Journal-Tag behält vier Gestaltungsauswahlen. „Persönliche Stimme“ bleibt eine Liste; optional zeigt sie knapp, welche Stilquellen die gewählte Ausprägung verwendet. Kein zusätzlicher Regler.
**Additiv 2026-08-29:** Die Liste enthält zusätzlich Legacy-Vergleichsausprägungen. Admin zeigt Vorgänger-ID und Revision.
**Additiv 2026-08-29:** API-Fehler (Profilanalyse, Journal, Dialog) zeigen Code, Providergrund und eine Kostennotiz (`cost_report.note`), nicht nur „Anfrage fehlgeschlagen“. `/api` wird im Service Worker als NetworkOnly geladen, damit lange Review-Läufe nicht an einem GET-Cache hängen. Der Dev-Proxy wartet bis zu zehn Minuten auf `/api`.
Die Dialogfläche bleibt in der Inhaltsäule der Shell. Lange Nachrichten umbrechen oder scrollen intern; sie dürfen die Seite nicht in der Breite aufweiten. Zeilenumbrüche und Leerzeilen in Nutzer- und Impulstexten bleiben in der Darstellung erhalten.
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.

View File

@ -155,6 +155,8 @@ Zusätzlich zu Layer 0:
- `generation_guidelines` hält versionierbare benannte Ausprägungen je Purpose und Slot. Seed aus JSON, veröffentlichte Zeilen werden nicht überschrieben, Reset erzeugt neue Drafts.
- `journal_drafts.generation_snapshot` hält den kompakten Lauf-Snapshot ohne Promptkörper und ohne persönliche Quellen.
- **Additiv 2026-08-27:** Persistierte `source_mode`-Zeilen in `generation_guidelines` werden archiviert und nicht mehr im Runtime-Pfad verwendet. `generation_snapshot` enthält keinen Quellenmodus und keinen Editorial Mode.
- **Additiv 2026-08-29:** `generation_guidelines.style_context_json` hält die Stilquellenfreigabe der Stilanwendungsvariante. Leere Altzeilen erhalten eine kompatible Default-Konfiguration; Systemseeds werden nur unangetastet aktualisiert.
- **Additiv 2026-08-29 (immutable Seeds):** Vorhandene Varianten-IDs werden durch Seeds nicht mehr fachlich überschrieben. Semantische Änderungen erzeugen eine Nachfolgezeile (`cloned_from`, neue ID). Legacy-Vergleichsstimmen haben eigene IDs.
- `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 `kansho.profile_analysis_package`/`kansho.profile_analysis_result`, API- oder Paste-Kanal, Proposal vor User-Acceptance, nachvollziehbare Stände. Kein Re-Infer nach jedem Save. Der Current Brief bleibt abgeleitetes Runtime-Artefakt und wird nicht als Profil importiert.
@ -164,7 +166,7 @@ Zusätzlich zu Layer 0:
`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.
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. **Additiv 2026-09-03:** Der *erste* Impuls eines neuen Gesprächs nutzt diese Ausschnitte nicht als Vorhaben und lädt sie nicht in den Opening-Prompt. Die Journalgenerierung bekommt denselben Tagesdialog inklusive Kanshō-Impulse als Gesprächskontext, nicht als Erlebtes.
## 7. Offene Fragen

View File

@ -37,7 +37,7 @@ Lokal, eine Instanz, ein Profil-Nutzer nach Setup:
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.
**Abweichung vom technischen Zielrahmen:** `product_frame_and_stack.md` nennt PostgreSQL. Der Slice läuft auf SQLite. Lokal zulässig; Prod-Pfad offen. Transfer der Urlaubsinstanz: zuerst SQLite-Restore auf dem Heimrechner, danach Compose/Postgres. Siehe `environment_handover.md` und `runtime_and_deploy.md` §7.2.
Health: `GET /api/health`.
@ -201,15 +201,19 @@ Gateway-Verfahren und offener Security Layer: `privacy_gateway.md` §9.19.2 u
| 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` |
| Detect-Vertragsretry, azyklische Diagnose, Persistenzschutz | `backend/tests/test_detect_contract_retry.py` |
| Request-scoped Maskierungsmanifest / Pre-Egress | `backend/tests/test_privacy_manifest.py` |
| Journal-Narration (Faktentreue, nicht Wortlaut) | `backend/tests/test_journal_narration.py` |
| Journal-Editorial Modes, Profil, Stilreferenzen | `backend/tests/test_journal_editorial.py` |
| Journal-Generation-Policy | `backend/tests/test_journal_generation_policy.py` |
| Journal-Stilkontext der Stilanwendung | `backend/tests/test_journal_style_context.py` |
| Journal-Legacy-Stimmen und immutable Seeds | `backend/tests/test_journal_style_legacy_immutable.py` |
| Journal-Eval-Vertrag (kein Live-Qualitätsbeleg) | `backend/tests/test_journal_eval.py` |
| Shape ohne Pronomenheuristik | `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` |
| Profile-Review-Fehlergrund und Kosten | `backend/tests/test_profile_review_errors.py` |
| Journal-Budget / zweistufige Generierung | `backend/tests/test_journal_budget.py` |
| Persistierte Admin-Testspur | `backend/tests/test_debug_persist.py` |
| Lokales Backup/Restore | `backend/tests/test_local_backup.py` |
@ -274,7 +278,7 @@ Additiv zum Slice. Fachliche Abnahme: `../functional/mvp_freeze_candidate.md`.
- Lokaler Testeinstieg `scripts/test-mvp.ps1` (UTF-8, isolierte Temp-Daten, keine Live-Keys; Fake nur in den Suites, die ihn setzen).
- Backup: `backend/local_backup.py`, Einstieg `scripts/backup-local.ps1`. SQLite-Backup-API, Medien, Manifest, keine Secrets. Restore bestätigt, prüft Checksummen, legt Sicherheitsbackup an, bricht bei geöffneter DB ab.
- Papierkorb: `GET /spaces/{id}/entries`, `GET /spaces/{id}/trash`, `POST /entries/{id}/undelete`, `POST /entries/{id}/purge`.
- Erster Impuls: `journal_opening.py`, derselbe Gateway-Pfad, `{{opening_hint}}`.
- Erster Impuls: `journal_opening.py`, derselbe Gateway-Pfad, `{{opening_hint}}`. **Additiv 2026-09-03:** Vorhaben nur aus dem user-Dialog des vorigen Kalendertags (`calendar_date` an `space_recent_sources`); Journal-Entries und ältere Recency sind kein Opening-Mandat. Der Opening-Call setzt `include_space_recency=False`. Tests: abgeschlossener Entry mit „nächsten Tag“ und Plan von vorgestern → `local_neutral`; „Morgen wollen wir …“ nur am Folgetag → Modellpfad. **Additiv 2026-09-07:** Prefix-Recency (5×400 Entries, 3×400 Source-Conversations) bleibt im *laufenden* Dialogzug; das ist kein Opening-Mandat. Ein Nachsatz mit Morgenuhrzeit darf den Folgetag nicht über den Entry-Anfang steuern.
- Home: `GET /continuity`.
Bewusst nicht: Docker, Postgres, Ollama, Verschlüsselung, Dialogmedien als Quelle, Space-Entries als Generate-Fakten.
@ -321,6 +325,8 @@ AI Review vergleicht Evidenz mit dem bestehenden Profil und liefert Änderungsvo
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.
**Additiv 2026-08-29 (Fehlergrund und Kosten bei API-Analyse):** `POST /writing-profile/review/api` und `.../initial-build/api` liefern bei Gateway- und Parserfehlern `detail.code`, `detail.message`, optional `http_status` / `provider_message` und `cost_report` (`billed` `yes`|`no`|`unknown`, Detect-/Generate-Kosten soweit der Anbieter sie gemeldet hat). Ein gültiger Generate-Call, dessen JSON lokal nicht übernommen wird, bleibt ein Fehler mit `generate_called=true` und ausgewiesenen Kosten. Die Settings-UI zeigt Grund und Kostennotiz, nicht nur „Anfrage fehlgeschlagen“. Mapping, Klartext und Promptkörper gehören nicht in diese Fehlerantwort. Tests: `backend/tests/test_profile_review_errors.py`.
Das statische Facet-Raster aus §12 ist **nicht** die Profilstruktur. Semantische Traits und der Initial Profile Build: §14. Kanonischer Analysevertrag, Copy/Paste als Testpfad und Import ohne Auto-Apply: §15. Die älteren `kind`-Namen bleiben als Legacy lesbar.
---
@ -474,6 +480,12 @@ Chunk-and-Merge für übergroße Tage, Tokenizer je Modellfamilie, persistente A
**Additiv 2026-08-28 (sichtbare Modellantwort):** `_normalize_active_cleartext` bleibt die interne Prüffassung. `complete()` speichert `_visible_response`: Originalantwort, nur ausgegebene Platzhalter rehydriert. Modell-Klartext behält die Oberflächenform.
**Additiv 2026-08-29 (Stilkontext der Stilanwendungsvariante):** `generation_guidelines.style_context_json` gehört zur Dimension `voice`. Seed `2026-08-29-voice-style-context-v1` legt die Ausprägungen `Ohne persönliches Profil`, `Persönliche Stimme dezent`, `Persönliche Stimme deutlich` und `Persönliche Stimme mit Beispielen` an und aktualisiert unangetastete Systemseeds; Klone und gespeicherte IDs bleiben. `compile_task_brief` filtert Core/Facet/Traits; Style Examples nur bei Freigabe. Leere WRITING_PROFILE-/STYLE_EXAMPLES-Blöcke entfallen. Prompt `mvp.journal_generate` `seed_revision` `2026-08-29-voice-style-context-v1`. Request-scoped Trace `style_application`: angeforderte vs. wirksame Quellen, Zeichen/Tokens je Block, Auslassungsgrund inkl. Budget. Admin bearbeitet Anweisung und Stilquellen gemeinsam. Generate-UI bleibt bei einer Auswahl „Persönliche Stimme“. Tests: `backend/tests/test_journal_style_context.py`.
**Additiv 2026-08-29 (Legacy-Vergleich und immutable Seeds):** Seed `2026-08-29-voice-legacy-immutable-v1` fügt vier Vergleichs-IDs `journal-generate-voice-legacy-*` hinzu, ohne die aktuellen Voice-IDs, Defaults oder gespeicherten Auswahlen zu ändern. `seed_generation_instructions` überschreibt vorhandene IDs nicht mehr; abweichende Semantik erzeugt eine Nachfolge-ID (`cloned_from`, erhöhte `revision`). Prompt `mvp.journal_generate` lässt den Standing-Satz zu `STYLE_EXAMPLES` weg, wenn kein Beispielblock gesendet wird. Tests: `backend/tests/test_journal_style_legacy_immutable.py`.
**Additiv 2026-08-29 (Detect-Vertragsretry und azyklische Fehlerdiagnose):** Ein ungültiger Detect-Vertrag verwirft den Pass und startet genau einen vollständigen Neuversuch mit allgemeiner Schemaanweisung. Der zweite Fehlschlag bleibt fail-closed vor dem Narrationsmodell. `trace.budget` ist ein Compact-Snapshot, kein Live-`diagnostics`. `sanitize` ist zyklensicher. Persistenzfehler ersetzen den `EngineError` nicht. Tests: `backend/tests/test_detect_contract_retry.py`.
Tests: `backend/tests/test_journal_budget.py`, `backend/tests/test_journal_narration.py`, `backend/tests/test_journal_editorial.py`, `backend/tests/test_journal_eval.py`, `backend/tests/test_journal_shape.py`, `backend/tests/test_privacy_response_integrity.py`. Allgemeine Provenienzschicht: `provenance_verification.md`, `backend/tests/test_provenance.py`.
---

View File

@ -157,6 +157,8 @@ Persönlicher Journal-Egress bleibt ausschließlich über dieses Gateway. Der no
Journal-Prompts werden nicht mehr durch `MAX_EGRESS_CHARS` still in der Mitte abgeschnitten. Passt Artefakt, Writing Profile und optionaler Bestand nicht ins Stufe-2-Budget, bricht Kanshō mit `prompt_budget_exceeded` ab. Stufe 2 erhält den vollständigen lokal rehydrierten Nutzerinhalt als `VerifiedArtifact` (`provenance_verification.md`); bei Übergröße ebenfalls Abbruch, kein stilles Kürzen.
**Additiv 2026-08-29:** Der von der Stilanwendungsvariante freigegebene Stilkontext (Writing-Profile-Core, Facet, Traits, Style Examples) durchläuft weiterhin vollständig das Privacy Gateway. `Ohne persönliches Profil` sendet keinen Profilinhalt und keine Style Examples. Nicht freigegebene Stilquellen fehlen im Egress, sie werden nicht nachträglich angehängt. Die Gateway-Policy selbst ändert sich nicht.
Usage-Metadaten (Tokens, Kosten, Fenster, Reserve, Marge, Compression-Status) liegen in der Admin-Testspur `trace.budget` bzw. `trace.stages`. Journal-Generate setzt lokale Stufe 1 und den Gateway-Call der Stufe 2 zusammen; `run_log` sammelt Detect, Pre-Egress, den Generate-Aufruf, Antwortnormalisierung und Provenienzentscheidung ohne Promptkörper. Es gibt keine globale `debug_history` und keinen globalen Promptspeicher. Vollständige Prompts und Antworten höchstens im direkten Admin-Response des aktuellen Requests. Mapping bleibt ausgeschlossen.
**Additiv 2026-08-28 (opt-in lokale Persistenz):** Admin kann unter `/admin/debug` eine lokale Debug-Persistenz einschalten. Default bleibt aus. Dann speichert Kanshō die Testspur jedes Admin-Schritts in `debug_runs`, zugeordnet zu Space / Tag / Gespräch. Mapping-Tabelle, Labels und Secrets bleiben ausgeschlossen. Kein In-Memory-Promptspeicher, kein Egress des Debug-Archivs, kein Bypass. Die Live-Anzeige unter dem Dialog entfällt; der Dialog bietet den Gesprächs-Download, solange der Schalter an ist. Der Journal-Entwurf zeigt die Testspur weiter und erlaubt den JSON-Download des Generate-Laufs. Export `kansho.debug_export` v2 (JSON-Baum plus chronologische `runs`, Markdown) nur für das eigene Admin-Profil. Nicht-Admin-Aktionen werden nicht geschrieben. Abschalten stoppt neue Writes; vorhandene Zeilen bleiben bis zum expliziten Löschen. Das ist keine Produktions-Audit-Spur und kein Ersatz für §9.2 Punkt 4.
@ -201,7 +203,11 @@ Untrusted Input:
{"entities":[{"start":42,"end":47,"text":"Anna","entity_type":"PERSON"}]}
```
Erlaubte Typen: `PERSON`, `PLACE`, `ORG`, `PROJECT`. `PROJECT` ist additiv entschieden für benannte private Vorhaben und Produktbezeichnungen, nicht für Allerweltsgegenstände. Zusätzliche Felder und Tokens werden abgelehnt. Falsche Offsets werden lokal am exakten Wortlaut im Chunk verankert, sofern das gemeldete `text` dort vorkommt; ein Wort, das im Chunk nicht existiert, wird verworfen, ohne Generate zu sperren. Überlappende Spans: längste Spanne, dann links, dann Typreihenfolge PERSON > PROJECT > ORG > PLACE. Tokens vergibt nur die lokale Schicht, request-scoped. Schemaverletzungen, Abbruch und unvollständige Chunks bleiben fail-closed. Im Dialogzug erzeugt ein solcher Fail-closed lokal einen Halte-Impuls, ohne den Generate-Provider zu rufen. Der Nutzer sieht keine interne Substring-Diagnose.
Erlaubte Typen: `PERSON`, `PLACE`, `ORG`, `PROJECT`. `PROJECT` ist additiv entschieden für benannte private Vorhaben und Produktbezeichnungen, nicht für Allerweltsgegenstände. Zusätzliche Felder und Tokens werden abgelehnt. Falsche Offsets werden lokal am exakten Wortlaut im Chunk verankert, sofern das gemeldete `text` dort vorkommt. Überlappende Spans: längste Spanne, dann links, dann Typreihenfolge PERSON > PROJECT > ORG > PLACE. Tokens vergibt nur die lokale Schicht, request-scoped.
**Additiv 2026-08-29 (Detect-Vertrag, ein vollständiger Neuversuch):** Eine formal ungültige Detector-Antwort (unbekannter `entity_type`, Extra-/Fehlfelder, ungültiges JSON, nicht verankerbarer Text, Abbruch/`finish_reason=length`, unvollständige Chunk-Abdeckung) verwirft den gesamten bisherigen request-lokalen Pass. Es folgt genau ein neuer vollständiger Pass über alle Chunks mit einer allgemeinen Schema-Korrekturanweisung, ohne textspezifische Namen oder Fehlertypen. Misslingt auch der zweite Pass: fail-closed, kein Generate, kein Journalentwurf. Netzwerk- und Timeout-Fehler (`detect_chunk_failed`) werden nicht durch denselben Vertrag-Retry verdoppelt. Ungültige Entitäten werden nicht still ignoriert, nicht auf erlaubte Typen umgedeutet und nicht durch Pattern-Fallback ersetzt. Compact-Fehlerdiagnose: `detect_passes`, Versuche mit Chunk-/Aufruf-/Coverage-Zahlen, aggregierte Detect-Tokens/Kosten/Dauer, `detect_partial_discarded`, `contract_violation`, bei unbekanntem Typ den gelieferten `entity_type` ohne Klartextspan, `generate_called: false`. Unbekannte Providerkosten bleiben `detect_cost_unknown`. Diagnoseobjekte sind azyklisch (`trace.budget` ist ein Snapshot, kein Live-`diagnostics`). Persistenzfehler ersetzen den ursprünglichen EngineError nicht.
Schemaverletzungen, Abbruch und unvollständige Chunks bleiben fail-closed. Im Dialogzug erzeugt ein solcher Fail-closed lokal einen Halte-Impuls, ohne den Generate-Provider zu rufen. Der Nutzer sieht keine interne Substring-Diagnose.
### RequestDetectionManifest vs. ConfirmedIdentityRegistry
@ -225,11 +231,13 @@ Mehrere Detect-Calls bei langen Prompts sind zulässig. Der Nutzer akzeptiert di
Normalfall nach erfolgreicher Detection: genau ein Generate-Call. Aktiver Klartext in der Modellantwort erzeugt keinen zweiten Generate-Aufruf.
**Additiv 2026-08-29:** Fehlerantworten der Journal- und Profilreview-API enthalten `cost_report`: ob Detect/Generate den Anbieter erreicht haben (`detect_called`, `generate_requested`, `generate_called`) und welche Kosten der Anbieter gemeldet hat. Ablehnung vor dem Socket bleibt `billed=no`. Ein gesendeter Generate-Call ohne Usage ist `unknown`. Positive Detect- oder Generate-Kosten sind `yes`. Die Nutzermeldung darf den Providergrund (HTTP-Status, gekürzte `error.message`) enthalten, nicht den Prompt und nicht die Mapping-Tabelle.
Bestätigte Registry-Zeilen und bestätigte Aliase werden im gesamten gerenderten Egress labelbasiert maskiert, auch wenn Detect denselben Wortlaut an anderer Stelle nicht gemeldet hat. Request-lokale Spans bleiben span-genau. Kurze bestätigte Namen (unter der Detect-Mindestlänge) bleiben maskierbar, sofern sie nicht strukturell unmaskierbar sind (Pronomen, Rollen, Uhrzeiten).
### Tests und Live-Qualität
Contract-Tests: `backend/tests/test_privacy_detect.py`, `backend/tests/test_identity_registry.py`, `backend/tests/test_privacy_response_integrity.py`. Sie beweisen Schema, Fail-closed, span-genaue Maskierung und Datenfluss, nicht semantische Modellleistung. Opt-in: `python entity_detect_eval.py --live` mit synthetischen Sätzen und exakten erwarteten Spans. Ohne diesen Lauf bleibt die Live-Qualität unbestätigt. Das aktuell konfigurierte `openai/gpt-4.1-nano` gilt durch reale False-Positive-Vorschläge qualitativ nicht als zuverlässig bestätigt; das Modell wird deshalb nicht stillschweigend gewechselt.
Contract-Tests: `backend/tests/test_privacy_detect.py`, `backend/tests/test_identity_registry.py`, `backend/tests/test_privacy_response_integrity.py`, `backend/tests/test_detect_contract_retry.py`. Sie beweisen Schema, Fail-closed, span-genaue Maskierung und Datenfluss, nicht semantische Modellleistung. Opt-in: `python entity_detect_eval.py --live` mit synthetischen Sätzen und exakten erwarteten Spans. Ohne diesen Lauf bleibt die Live-Qualität unbestätigt. Das aktuell konfigurierte `openai/gpt-4.1-nano` gilt durch reale False-Positive-Vorschläge qualitativ nicht als zuverlässig bestätigt; das Modell wird deshalb nicht stillschweigend gewechselt.
## 10. Offene Fragen

View File

@ -2,7 +2,7 @@
title: "Kanshō Runtime, Migrationen und Deploy"
version: "0.1"
status: "Arbeitsstand"
date: "2026-08-19"
date: "2026-09-07"
product_family: "Jinkendo"
document_role: "Technical Chapter / Runtime / Deploy / Migrations"
parent_document: "technische_zielarchitektur.md"
@ -89,12 +89,14 @@ Kanshō-Repo liegt bereits auf Gitea (`Lars/Kansho`). HTTPS-Push ist eingerichte
| Ports/Domains/Hostpfade | Prod offen; lokal Frontend 5188 / Backend 8018 | lokal festgelegt, Prod offen |
| Gitea Workflows | Mitai-Muster | bevorzugte Richtung |
| Auto-Rollback | nein | verworfen |
| Urlaubs-Laptop ohne Docker/SQLite | Ist bis Transfer Welle 1 | dokumentiert 2026-09-07 |
| Transfer vor Postgres | SQLite-Restore auf dem Heimrechner zuerst | entschieden als Reihenfolge |
## 7. Offene Fragen
1. Läuft Kanshō auf demselben Raspberry-Pi-Host wie Mitai, mit eigenen Compose-Projekten?
2. Gemeinsames oder separates Postgres?
3. Backup-Rhythmus und Restore-Übung vor erstem persönlichen Dialogdatenbestand.
3. Backup-Rhythmus und Restore-Übung vor erstem persönlichen Dialogdatenbestand. Die Urlaubs-SQLite-Restore auf dem Heimrechner (Welle 1) ist diese Übung für den bestehenden Datenbestand; Produktions-Backup für Postgres bleibt offen.
### 7.1 Lokales Backup für die Urlaubs-Testphase (2026-08-25)
@ -105,8 +107,20 @@ Kein Ersatz für Produktions-Backup, Docker oder Verschlüsselung.
- Wiederherstellen: `.\scripts\backup-local.ps1 restore -Archive <pfad> -Confirm`; bestehender Stand nur mit `--replace` nach automatischem Sicherheitsbackup
- SQLite-Backup-API, Journalmedien, Manifest mit Checksummen. Keine `.env` oder Keys. Das Archiv kann persönliche unverschlüsselte Daten enthalten.
### 7.2 Transfer Laptop → Heim-Entwicklung und Server (2026-09-07)
Die Urlaubsinstanz lief ohne Docker auf SQLite (`backend/data/kansho.sqlite`, Ports 5188/8018). Persönliche Dialog- und Journaldaten liegen nicht in Gitea.
**Reihenfolge entschieden:** zuerst Kontinuität (Clone + SQLite-Restore + Smoke), danach Betriebsrahmen (Compose, Postgres 16, Gitea-Runner). Postgres bleibt Ziel; `backend/db.py` ist weiterhin SQLite. Ein direkter Sprung „Laptop-SQLite nach Prod-Postgres“ ohne Zwischen-Restore ist verworfen.
Kanonisches Session-Handover: `environment_handover.md`. Aufträge: `../../work_orders/laptop_closeout.md`, `../../work_orders/home_environment_setup.md`.
Laptop-Backup dieser Session: `local-backups/kansho-20260907-055703.zip` (nur lokal, gitignoriert). `.env` reist getrennt.
Noch nicht im Repository: Dockerfiles, Compose, `.gitea/workflows/`, Postgres-Adapter. Mitai unter `C:\dev\mitai` bleibt Muster, nicht Quelle für Ports oder `bodytrack/`-Pfade.
## 8. Querverweise
- Technisch: `product_frame_and_stack.md`, `security.md`
- Technisch: `product_frame_and_stack.md`, `security.md`, `environment_handover.md`, `mvp_implementation.md` §1
- Fachlich: Offline und Sync bleiben `memory_storage_and_offline.md`; Deploy löst Offline nicht.
- Mitai: `MIGRATIONS.md`, `.gitea/workflows/`

View File

@ -0,0 +1,204 @@
---
title: "Kanshō Arbeitsauftrag Debug-Diagnostik und Export"
status: "Arbeitsauftrag"
date: "2026-08-31"
document_role: "Cursor Implementation Brief"
canonical_architecture: "docs/architecture/technical/admin_diagnostics.md"
---
# Arbeitsauftrag: Debug-Diagnostik und Export konsolidieren
Arbeite im aktiven Repository `C:\dev\Kansho`.
Dieses Dokument ist ein Umsetzungsauftrag. Es ersetzt keine kanonische Fach- oder Technikdokumentation. Neue Entscheidungen und der tatsächliche Implementierungsstand sind additiv in den jeweils zuständigen Dateien unter `docs/architecture/` zu dokumentieren.
## Ausgangslage
Die bestehende Diagnostik enthält bereits viele wertvolle Informationen. Sie ist aber über verschiedene Seiten und Exportwege verteilt. Je nachdem, ob ein Export aus einem Dialog, einem Tag oder einer Generierung gestartet wird, entsteht ein anderer und teilweise sehr großer JSON-Export.
Dadurch ist oft unklar:
- welche Läufe im Export enthalten sind,
- welche Dialoge und Generierungen zu einem Tag gehören,
- warum ein Lauf fehlgeschlagen ist,
- welche Fassung tatsächlich angezeigt oder gespeichert wurde,
- und an welcher Stelle der passende Export ausgelöst werden muss.
Teilweise werden dieselben Daten mehrfach eingebettet. Das macht Exporte unnötig groß und ihre Analyse schwierig.
Die bereits ergänzte Speicherung fehlgeschlagener Detect- und Journal-Läufe muss erhalten bleiben.
## Ziel
Baue eine zentrale, verständliche Admin-Diagnostik mit einem einheitlichen Exportverfahren.
Ein Administrator soll gezielt auswählen können:
1. welchen Reflection Space,
2. welchen Tag oder Zeitraum,
3. welche Dialoge,
4. welche Generierungsläufe auch mehrere,
5. welche Laufarten und Status,
6. und welche Detailinformationen
der Export enthalten soll.
Die Diagnose soll sowohl für eine schnelle Sichtprüfung in der Oberfläche als auch für eine genaue externe Analyse des JSON geeignet sein.
## Vor der Umsetzung
Lies zunächst:
- `docs/architecture/functional/documentation_index.md`,
- `docs/architecture/technical/documentation_index.md`,
- `docs/architecture/technical/admin_diagnostics.md`,
- `docs/architecture/technical/privacy_gateway.md`,
- die bestehenden Debug-Routen, Datenmodelle und Frontend-Komponenten.
Prüfe die tatsächliche Implementierung und beschreibe kurz:
- die aktuellen Exportwege,
- vorhandene Doppelungen,
- fehlende Beziehungen,
- und welche Informationen bei Fehlern noch verloren gehen.
Leite die konkrete technische Lösung daraus ab. Übernimm nicht ungeprüft Annahmen aus diesem Auftrag.
## Anforderungen
### Zentrale Auswahl
Unter `/admin/debug` soll es einen zentralen Exportdialog geben.
Filter:
- Reflection Space,
- einzelner Tag oder Zeitraum,
- Dialoge als Mehrfachauswahl,
- Laufart beziehungsweise Purpose,
- Status,
- einzelne Run-IDs,
- optional Generierungszeitraum.
Vor dem Export soll sichtbar sein, wie viele Dialoge und Läufe durch die Auswahl erfasst werden.
### Auswählbare Inhalte
Der Administrator soll bestimmen können, welche Bereiche enthalten sind:
- kompakte Zusammenfassung,
- chronologische Ereignis- und Aufrufübersicht,
- Modell, Provider, Token, Kosten und Laufzeiten,
- Generierungsrichtlinien und wirksame Auswahl,
- wirksame Stilanwendung einschließlich tatsächlich eingebundener Profilbestandteile,
- verwendete Quellen,
- Privacy- und Detection-Diagnostik,
- Validierung und Fehlerursachen,
- Provideranfrage und Providerantwort,
- sichtbare und gespeicherte Ausgabe,
- vollständige Prompts und maskierter Provider-Egress.
Sensible Inhalte wie vollständige Prompts, Quellen und Rohantworten müssen ausdrücklich zugeschaltet werden. Zeige dabei einen klaren Warnhinweis.
Lokale Mappingtabellen, Secrets, Zugangsdaten und andere nicht benötigte Identitätsdaten dürfen niemals exportiert werden.
### Einheitliches Datenmodell
Erzeuge ein versioniertes, kanonisches Exportformat, beispielsweise Version 3.
Eine Information soll nur einmal enthalten sein. Beziehungen werden über IDs dargestellt, nicht durch wiederholtes Einbetten desselben Laufes unter Tag, Dialog und Generierung.
Eine mögliche oberste Struktur ist:
- `manifest`,
- `selection`,
- `entities`,
- `runs`.
Die konkrete Struktur soll aus dem bestehenden Datenmodell abgeleitet werden.
Bestehende Exportwege dürfen entweder kompatibel weiterlaufen oder müssen kontrolliert auf das neue Format umgestellt werden. Dokumentiere die Entscheidung.
### Vollständige Laufhistorie
Ein diagnostischer Lauf muss möglichst ab Beginn der Anfrage eine stabile Run-ID besitzen. Auch frühe Fehler sollen nachvollziehbar sein.
Unterscheide mindestens:
- laufend,
- erfolgreich,
- vor dem Provider fehlgeschlagen,
- Providerfehler,
- Detection fehlgeschlagen,
- Validierung fehlgeschlagen,
- Timeout,
- Clientabbruch,
- unvollständig beziehungsweise verwaist.
Vorhandene Statusbezeichnungen können weiterverwendet werden, wenn sie diese Fälle eindeutig abdecken.
Kosten oder Tokenwerte, die der Provider nicht geliefert hat, müssen als „nicht verfügbar“ beziehungsweise `null` erscheinen und dürfen nicht geschätzt als tatsächliche Werte ausgegeben werden.
### Einheitliche Einstiegspunkte
Export-Schaltflächen auf Dialog-, Tages- oder Generierungsseiten sollen keine eigenen Exportlogiken mehr besitzen. Sie öffnen denselben zentralen Exportdialog mit sinnvoll vorbelegten Filtern.
### Übersichtlichkeit
Die Adminoberfläche soll zuerst eine kompakte Übersicht zeigen. Technische Details werden erst bei Bedarf aufgeklappt.
Wichtig sind insbesondere:
- Status und Abbruchgrund,
- Dauer,
- Anzahl der Provideraufrufe,
- verwendetes Modell,
- Kosten und Token,
- Generate tatsächlich aufgerufen: ja/nein,
- Fallback verwendet: ja/nein,
- sichtbare und gespeicherte Fassung identisch: ja/nein.
## Grenzen
- Keine Änderung an Journal-Prompts oder Generierungsrichtlinien.
- Keine Änderung der semantischen Detection.
- Keine Lockerung der Privacy-Regeln.
- Keine journal-spezifische Sonderarchitektur für allgemeine Runs.
- Keine zukünftige Memory-Architektur vorwegnehmen.
- Keine echten Provideraufrufe.
- Kein Commit und kein Tag.
- Bestehende uncommittete Änderungen erhalten.
## Tests und Abnahme
Ergänze Regressionstests für mindestens:
1. Export eines einzelnen Dialogs.
2. Export eines Tages mit mehreren Dialogen.
3. Auswahl mehrerer Generierungsläufe.
4. Kombination erfolgreicher und fehlgeschlagener Läufe.
5. Persistenz eines Fehlers vor dem Generate-Aufruf.
6. Exakte Anwendung aller Filter.
7. Keine doppelte Einbettung eines Runs.
8. Sensible Inhalte nur nach ausdrücklicher Auswahl.
9. Niemals Secrets oder Identitäts-Mappingtabellen im Export.
10. Nicht-Admins erhalten keinen Zugriff.
11. Seitenspezifische Schaltflächen öffnen den zentralen Export mit korrekter Vorbelegung.
12. Bestehende MVP-Tests und Frontend-Build bleiben erfolgreich.
Dokumentiere die Änderung additiv in der bestehenden technischen Diagnostik-Dokumentation.
## Abschlussbericht
Berichte anschließend:
1. bestätigte Ursachen,
2. fachliches Bedienkonzept,
3. neues Exportmodell und Versionsstrategie,
4. geänderte Dateien,
5. Behandlung erfolgreicher und fehlgeschlagener Läufe,
6. Datenschutzgrenzen,
7. ausgeführte Tests,
8. bewusst verbleibende MVP-Grenzen.
Nenne ausdrücklich, was nur durch Tests bewiesen wurde und was noch interaktiv im Browser geprüft werden sollte.

View File

@ -0,0 +1,229 @@
---
title: "Kanshō Handover Dialogfähigkeit und Langzeitgedächtnis"
status: "Arbeitsauftrag für neue Konzept-Session"
date: "2026-08-31"
document_role: "ChatGPT Session Brief / Next-Slice Handover"
canonical_handover: "docs/architecture/functional/handover.md"
---
# Handover: Kanshō als persönlicher Reflexionspartner mit Langzeitgedächtnis
Wir beginnen einen neuen Entwicklungsslice für Kanshō.
Dieses Dokument ist ein thematischer Arbeitsauftrag für die nächste Session. Es ersetzt weder `docs/architecture/functional/handover.md` noch die kanonischen Fachkapitel. Bei Abweichungen haben die aktuellen kanonischen Repository-Dateien Vorrang.
## Produktverständnis
Kanshō ist kein Tagebuchtool. Die Journalgenerierung ist lediglich eine mögliche Ausgabe eines Reflexionsprozesses.
Kanshō soll ein persönlicher, langfristiger Reflexionspartner werden, der Menschen dabei unterstützt, Erlebtes wahrzunehmen, zu reflektieren, zu verstehen und einzuordnen.
Die zentrale Produktfrage lautet:
> Was bedeutet das Erlebte für die Person?
Kanshō ist ausdrücklich kein generischer Chatbot, Therapeut, Aufgabenmanager, Fitness-Tracker oder still arbeitendes Persönlichkeitsprofil.
Die Dialogfähigkeit ist die zentrale Produktschnittstelle. Langzeitgedächtnis soll der Kontinuität und Tiefe des Dialogs dienen nicht möglichst viele alte Informationen in jeden Prompt laden.
## Bestehender Stand
Der bisherige MVP konzentrierte sich stark auf Dialogerfassung, Journalgenerierung, Writing Profile, Privacy Gateway, Provenienz und konfigurierbare Generierungsrichtlinien.
Dieser Journal-MVP wird praktisch getestet und soll durch den neuen Slice nicht destabilisiert werden.
Aus den bisherigen Arbeiten sind besonders wichtig:
- Identität bleibt lokal.
- Externe Modelle erhalten persönlichen Kontext nur über das Privacy Gateway.
- Quellen bleiben autoritativ; Zusammenfassungen und Interpretationen sind abgeleitet.
- KI-Hypothesen sind keine bestätigten Erinnerungen.
- Aktueller Zustand ist nicht automatisch stabile Identität.
- Nutzerkorrekturen müssen gespeicherte Annahmen wirksam korrigieren können.
- Kontext soll relevant und ausreichend sein, nicht maximal.
- Der Normalfall soll möglichst einen wesentlichen generativen Modellaufruf pro Dialogzug behalten.
- Reflection Spaces und Intents dürfen nicht mit der Journalfunktion gleichgesetzt werden.
## Verbindliche Dokumentationsbasis
Arbeite im Repository `C:\dev\Kansho`.
Lies zuerst vollständig:
- `docs/architecture/functional/handover.md`,
- `docs/architecture/functional/documentation_index.md`,
- bei technischer Prüfung zusätzlich `docs/architecture/technical/documentation_index.md`.
Lade danach gezielt die passenden Context Bundles. Relevant sind insbesondere:
- `produktvision_und_produktidentitaet.md`,
- `fachliche_zielarchitektur.md`,
- `dialogue_model.md`,
- `usage_situations.md`,
- `reflection_spaces.md`,
- `resurfacing_and_saturation.md`,
- `memory_and_context.md`,
- `context_fidelity_and_regrounding.md`,
- `self_model_and_lived_experience.md`,
- `guardrails.md`.
Lade nicht unkritisch den gesamten Dokumentationsbaum. Die fachliche Dokumentation ist führend. Unterscheide konsequent zwischen „Entschieden“, „Bevorzugte Richtung“, „Hypothese“, „Offen“ und „Verworfen“.
Dokumente dürfen nicht stillschweigend gekürzt oder durch neue Paralleldokumente ersetzt werden.
## Fachliches Ziel des nächsten Slices
Der nächste Slice soll erstmals belastbar zeigen, dass Kanshō über einzelne Sitzungen hinaus als persönlicher Reflexionspartner funktioniert.
Kanshō soll:
- den gegenwärtigen Reflexionsraum erkennen und halten,
- unterschiedliche Reflection Intents unterscheiden,
- frühere relevante Gedanken gezielt wieder aufnehmen,
- mehrere offene Reflexionsfäden erkennen, parken und fortsetzen,
- historische Aussagen von der heutigen Sicht unterscheiden,
- Veränderungen über die Zeit sichtbar machen,
- Unsicherheit und widersprüchliche Aussagen aushalten,
- Erinnerungen mit Herkunft und zeitlichem Bezug verwenden,
- Korrekturen des Nutzers beachten,
- und Impulse geben, die über generische Rückfragen hinausgehen.
**Additiv 2026-09-07 (Opening, Code-Stand):** Der erste Impuls eines Journal-Dialogs darf abgeschlossene Vortage nicht als offenen Faden führen. Ursache war Recency: 400-Zeichen-Prefix eines Journal-Entries plus Plan-Regex, oft nach einem nachgetragenen Morgenanker. Invariante: Vorhaben nur aus dem user-Dialog von gestern; Entries sind kein Mandat; Opening ohne Space-Recency. Der laufende Dialogzug lädt Prefix-Recency weiter das bleibt eine Lücke für Relevanz/Memory, nicht für den Opening-Adapter. Details: `mvp.md` §6.1, `mvp_implementation.md` §17, `mvp_stand_und_abgleich.md` §6 Punkt 4.
Dabei darf das System keine künstliche Gewissheit, Diagnose oder festgeschriebene Persönlichkeit erzeugen.
## Reflection Spaces und Intents
Reflection Spaces beantworten, in welchem Lebens- oder Bedeutungskontext eine Reflexion stattfindet. Intents beschreiben, was der Nutzer in diesem Moment erreichen möchte.
Der Dialog muss unterschiedliche Formen unterstützen, beispielsweise:
- ein Erlebnis zunächst nur erzählen,
- einen spontanen Gedanken festhalten,
- ein früheres Thema fortsetzen,
- ein Erlebnis tiefer verstehen,
- wiederkehrende Muster betrachten,
- eine Entscheidung oder Orientierung reflektieren,
- mit einem Konflikt oder einer emotionalen Reaktion arbeiten,
- eine tägliche Reflexion führen,
- Meditation oder Aufmerksamkeit reflektieren.
Es darf keinen einzigen starren Dialogmodus und keinen universellen Prompt geben, der alle Situationen gleich behandelt.
Ebenso darf das System den Nutzer nicht ständig zwingen, einen Modus, Space oder Intent manuell auszuwählen. Die Adaptive Entry bleibt maßgeblich:
1. kontextuell fortsetzen, wenn der Bezug klar ist,
2. freie Reflexion ermöglichen,
3. nur bei echter Mehrdeutigkeit explizit navigieren.
Gemischte oder sich während eines Dialogs verändernde Intents müssen möglich bleiben.
## Arbeitsauftrag für diesen Chat
### Phase 1: Bestehenden Stand verstehen
Prüfe fachliche Dokumentation und aktuelle Implementierung. Untersuche insbesondere:
- aktuellen Dialogprompt und Dialog-Orchestrierung,
- Speicherung von Nachrichten, Dialogen und Reflection Spaces,
- Context Builder und Retrieval,
- vorhandene Thread-, Episoden- und Memory-Strukturen,
- Status, Provenienz und Korrekturmöglichkeiten gespeicherter Inhalte,
- Privacy-Pfad für angereicherten Kontext,
- aktuelle Tests und dokumentierte Fit-Gaps.
Trenne danach klar:
1. fachlich bereits entschiedene Zielarchitektur,
2. technisch bereits belastbar umgesetzt,
3. nur als MVP-Annäherung vorhanden,
4. offen oder widersprüchlich,
5. für den nächsten Slice wirklich erforderlich.
### Phase 2: Nächsten vertikalen Slice entwerfen
Schlage keinen vollständigen Ausbau des gesamten Langzeitgedächtnisses vor. Definiere den kleinsten vertikalen Slice, der einen echten qualitativen Fortschritt im Dialog nachweist.
Der Slice soll mindestens umfassen:
- Intent-sensitive Dialogführung,
- Fortsetzung eines früheren Reflexionsfadens,
- gezieltes lokales Retrieval relevanter Erinnerungen,
- Unterscheidung historischer und aktueller Aussagen,
- sichtbare Herkunft beziehungsweise Re-Grounding,
- Nutzerkorrektur einer früheren Annahme,
- und eine normale Unterhaltung ohne relevante Erinnerung als Kontrollfall.
Der allgemeine Kern muss intent-neutral sein. Intent-spezifische Regeln gehören in Adapter oder Policies und nicht als Sonderfälle in die allgemeine Memory-Infrastruktur.
### Phase 3: Eval- und Abnahmekonzept
Entwickle vor der Implementierung reproduzierbare Dialogszenarien für mindestens:
1. autobiografisches Erzählen ohne Reflexionsdruck,
2. spontanen Gedanken,
3. Fortsetzung eines Themas nach mehreren Tagen,
4. tiefere biografische Reflexion,
5. Entscheidung oder Orientierung,
6. Konflikt beziehungsweise emotionale Reaktion,
7. Kontrollfall ohne relevante Erinnerung.
Bewerte dabei nicht nur Faktenabruf, sondern:
- Natürlichkeit des Dialogs,
- Qualität und Tiefe der Impulse,
- passende Intensität der Reflexion,
- relevante statt maximale Erinnerung,
- keine unpassenden historischen Einwürfe,
- korrekte zeitliche Einordnung,
- Umgang mit Unsicherheit und Widerspruch,
- Reaktion auf Nutzerkorrekturen,
- Vermeidung repetitiver oder generischer Fragen,
- Datenschutz,
- Latenz und Kosten.
### Phase 4: Entscheidungsvorlage
Erstelle zunächst:
1. eine kompakte Fit-Gap-Matrix,
2. den vorgeschlagenen nächsten Slice,
3. dessen fachliche und technische Grenzen,
4. konkrete Abnahmekriterien,
5. notwendige offene Produktentscheidungen,
6. und eine empfohlene Umsetzungsreihenfolge.
Eine bereits bekannte offene Frage ist die Positionierung bei Entscheidungsdialogen: Darf Kanshō nach ausreichender Reflexion eine begründete Einschätzung oder Empfehlung geben, oder soll es bewusst neutral bleiben? Behandle dies als intent-spezifische Produktentscheidung, nicht als Grundsatz für alle Dialoge.
Beginne noch nicht mit einer großen Implementierung. Stelle zuerst den tatsächlichen Stand und den vorgeschlagenen Slice zur gemeinsamen Entscheidung vor. Frage nur bei Punkten nach, die sich weder aus Dokumentation noch Code zuverlässig beantworten lassen.
## Architekturgrenzen
Vermeide insbesondere:
- das vollständige Langzeitgedächtnis in jeden Prompt zu laden,
- semantische Ähnlichkeit mit fachlicher Relevanz gleichzusetzen,
- jeden Dialogzug durch mehrere teure Analyseaufrufe zu schicken,
- Journallogik zum allgemeinen Dialog- oder Memory-Kern zu machen,
- unbestätigte Modellinterpretationen als Nutzerwissen zu speichern,
- aus aktuellen Aussagen stabile Persönlichkeitsmerkmale abzuleiten,
- Reflection Spaces unnötig zu vervielfachen,
- für einzelne Tests hardcodierte Regeln einzubauen,
- und die bestehende Privacy-Architektur für bessere Modellqualität zu umgehen.
Der Kontextaufbau soll grundsätzlich lokal erfolgen:
> Reflection Space zuerst, Langzeitgedächtnis gezielt bei Bedarf, minimal ausreichender Kontext, danach Privacy Gateway.
## Erwartete erste Antwort
Antworte nach der Einarbeitung zunächst mit:
1. den tatsächlich gelesenen Dokumenten,
2. dem bereits entschiedenen Produkt- und Architekturstand,
3. dem ermittelten Ist-Stand der Dialog- und Memory-Implementierung,
4. den wichtigsten Lücken,
5. einem Vorschlag für den ersten vertikalen Slice,
6. den Punkten, die wir gemeinsam entscheiden müssen.
Kanshō soll in diesem neuen Slice nicht zu einem besseren Tagebuchgenerator werden, sondern erstmals erkennbar zu einem kontinuierlichen, persönlichen und verlässlichen Reflexionspartner.

View File

@ -0,0 +1,158 @@
---
title: "Kanshō Arbeitsauftrag Heim-Entwicklung, Server, Docker, Postgres"
status: "Arbeitsauftrag für die Session nach dem Laptop-Transfer"
date: "2026-09-07"
document_role: "Cursor Implementation Brief / Home Environment Setup"
canonical_handover: "docs/architecture/technical/environment_handover.md"
---
# Arbeitsauftrag: Heim-Entwicklungsumgebung, Server und Datenübernahme
Arbeite **nicht** auf der Urlaubs-SQLite als führende Instanz. Voraussetzung: `laptop_closeout.md` ist erfüllt (Gitea aktuell, Backup und `.env` liegen vor).
Lies zuerst:
- `docs/architecture/technical/environment_handover.md`
- `docs/architecture/technical/documentation_index.md`
- `docs/architecture/technical/runtime_and_deploy.md`
- `docs/architecture/technical/product_frame_and_stack.md`
- `docs/architecture/technical/mvp_implementation.md` §1 und §4
- fachlich nur `docs/architecture/functional/guardrails.md`, falls Egress oder Mapping berührt wird
Mitai-Muster liegen unter `C:\dev\mitai` (Compose, Workflows). Kanshō-Ports, Host-Pfade und Datenbanken nicht aus Mitai kopieren.
Keine Slice-2- oder Memory-Implementierung in diesem Auftrag. Kein Commit, bis der Nutzer ihn verlangt.
---
## Offene Host-Fragen, vor Phase B klären
In `runtime_and_deploy.md` §1/§6/§7 eintragen, nicht nur im Chat lassen:
1. Derselbe Raspberry-Pi wie Mitai, eigenes Compose-Projekt?
2. Eigenes Postgres (empfohlen) oder gemeinsame Instanz mit getrennter Database?
3. Host-Pfade analog `/home/lars/docker/…` konkrete Kanshō-Verzeichnisse?
4. Dev-/Prod-Domains und **Kanshō-Ports** (lokal bleiben 5188/8018)?
5. Welle 2 zuerst SQLite im Volume oder direkt Postgres?
Solange 14 offen sind: nur Phase A ausführen.
---
## Phase A Kontinuität (SQLite, ohne Docker)
Ziel: Dieselbe Anwendung wie auf dem Laptop, mit den Urlaubsdaten, auf dem Heimrechner.
1. `git clone https://gitea.stommer.de/Lars/Kansho.git` (Windows-Pfadempfehlung `C:\dev\Kansho`, wenn frei).
2. `git log -1` muss den Opening-Fix und die Transfer-Doku enthalten. Fehlt das, zuerst Laptop-Push nachholen.
3. `.\scripts\dev-setup.ps1`
4. `backend/.env` aus der Secret-Kopie. Nicht aus dem Backup-Zip.
5. Backend aus, dann Restore:
```powershell
.\scripts\backup-local.ps1 restore -Archive <pfad>\kansho-20260907-055703.zip -Confirm -Replace
```
6. Start: Backend `--port 8018`, Frontend `npm run dev` (5188).
7. Abnahme Phase A:
- [ ] `GET /api/health`
- [ ] Login mit bestehendem Admin
- [ ] Space `Kroatien 2026` und Test-Space sichtbar
- [ ] Journal Days und mindestens ein Entry mit Medien
- [ ] Identity-Mappings unter Admin vorhanden, Keys nicht in der DB
- [ ] Ein Dialogzug: ohne gültige Policy fail-closed; mit konfiguriertem Generate über Privacy Gateway
- [ ] `.\scripts\test-mvp.ps1` grün (Temp-Isolation)
Danach ist der Laptop nicht mehr Source of Truth. Weitere Dialoge nur noch hier.
---
## Phase B Docker-Gerüst (noch ohne fachliche Postgres-Migration, falls so entschieden)
Erst nach beantworteten Host-Fragen.
Muster Mitai, eigene Namen:
- `docker-compose.yml` (Prod-ähnlich: Frontend-Nginx, Backend, Postgres 16)
- `docker-compose.dev.yml` / `docker-compose.dev-env.yml` nach Familienbrauch
- `backend/Dockerfile`, `frontend/Dockerfile`
- Root-`.env.example` für Compose-Secrets (DB-Passwort, Provider-Keys). Bestehendes `backend/.env.example` nicht stillschweigend ersetzen.
- Volumes für App-Daten und Medien. Keine Mitai-Volume-Namen (`bodytrack_*`).
- Healthcheck Backend; Startup: DB ready → Migrationen → Uvicorn.
- Lokale Ports **nicht** 3002/8002/5173/8000.
Zwischenstand zulässig: Compose startet Backend weiterhin auf SQLite im Volume, Postgres-Container schon mitlaufen, App noch nicht umgestellt. Das muss in `runtime_and_deploy.md` explizit stehen.
Abnahme Phase B:
- [ ] `docker compose up` startet die drei Dienste
- [ ] Frontend erreicht Backend über `/api`
- [ ] Secrets nur in Host-`.env`
- [ ] Re-Restore oder Volume-Mount der Phase-A-Daten dokumentiert
- [ ] `scripts/test-mvp.ps1` bleibt der lokale Nicht-Docker-Weg
---
## Phase C PostgreSQL als App-Persistenz
Eigene Implementierung, kein stilles Umbenennen von `db.py`.
1. Adapter oder Dual-Backend: SQLite bleibt für Laptop/Tests zulässig, bis Postgres abgenommen ist.
2. `schema.sql` und die bestehenden `schema_migrations` nach Postgres-Dialekt führen (kein `PRAGMA`, `datetime('now')` prüfen, Typen).
3. Startpfad wie entschieden: Postgres ready → nummerierte SQL-Dateien → App. Fail-fast.
4. Einmaliges Migrationsskript SQLite → Postgres (Profile, Spaces, Journal, Medien-Metadaten, Mappings, Prompts). Medien-Dateien separat ins Volume.
5. Probe auf Kopie, nicht auf dem einzigen Backup.
6. Danach Backup-Strategie für Postgres (nicht nur `backup-local.ps1`). Restore-Übung wiederholen.
Abnahme Phase C:
- [ ] Leere Instanz startet und migriert
- [ ] Übernommene Urlaubsdaten: Entry-Anzahl, Message-Anzahl, ein Medien-GET
- [ ] Privacy Gateway unverändert fail-closed
- [ ] Tests gegen Postgres oder klar getrennte SQLite-Testmatrix
- [ ] Additive Doku in `runtime_and_deploy.md`, `mvp_implementation.md` §1/§4, `data_architecture.md` nur soweit Persistenzgrenzen betroffen sind
---
## Phase D Gitea CI/CD
Erst wenn Runner und Host-Pfade existieren (`runtime_and_deploy.md` §4).
Vorlage: `C:\dev\mitai\.gitea\workflows\` (`deploy-dev.yml`, `deploy-prod.yml`, `test.yml`).
- Branch `develop` anlegen, wenn Dev-Deploy gewünscht
- Kanshō-Pfade, nicht `/home/lars/docker/bodytrack`
- Healthcheck auf Kanshō-Ports
- Testjob: Backend-Tests + Frontend-Build; keine Live-Provider-Keys im Runner
---
## Phase E Transfer testen (Ende-zu-Ende)
Nach Phase A zwingend, nach B/C erneut:
1. Neues Backup auf der Heiminstanz erzeugen und an einem Temp-Pfad wiederherstellen.
2. Einen Journal Day öffnen, kurzen Dialog, Generate nur wenn Keys gesetzt.
3. Opening-Invariante: abgeschlossener Vortag erzeugt keinen falschen Plan-Impuls.
4. Admin → Schnittstellen: Keys aus `.env`, nicht aus der DB.
5. Kein Klartext-Detect in `KANSHO_ENV=production`.
6. Regression: `.\scripts\test-mvp.ps1`
---
## Grenzen
- Keine Mitai-Körperlogik, keine gemeinsamen Tabellen mit Mitai.
- Keine Lockerung der Guardrails „weil Docker intern ist“.
- Keine Produktiv-Domain erfinden.
- Watchtower nicht still einschalten.
- `Idea_seconmd_sclide.md` und Memory-Handover nicht in diesem Auftrag implementieren.
## Abschlussbericht
1. Welche Phasen tatsächlich gelaufen sind
2. Gewählte Host-Pfade, Ports, Compose-Projektnamen
3. Ob Daten SQLite oder Postgres führen
4. Restore-Nachweis (Anzahlen, ein Medienbeispiel ohne Inhalt zu zitieren)
5. Offene Punkte für die nächste Session

View File

@ -0,0 +1,87 @@
---
title: "Kanshō Arbeitsauftrag Laptop-Abschluss vor dem Heimtransfer"
status: "Arbeitsauftrag / teilweise erledigt"
date: "2026-09-07"
document_role: "Cursor Implementation Brief / Laptop Closeout"
canonical_handover: "docs/architecture/technical/environment_handover.md"
---
# Arbeitsauftrag: Laptop-Abschluss
Arbeite im Repository `C:\dev\Kansho` auf dem **Urlaubs-Laptop**. Ziel ist, alles zu sichern und nach Gitea zu bringen, was zu Hause nicht mehr aus Cursor-Sessions rekonstruierbar ist.
Dieses Dokument ersetzt nicht `docs/architecture/technical/environment_handover.md`. Docker, Postgres und Server-Deploy gehören **nicht** hierher, sondern nach `home_environment_setup.md`.
## Bereits in der Transfer-Session erledigt (2026-09-07)
- Opening-Fix gegen veraltete Recency-Impulse: Code vorhanden, `python tests/test_journal_opening.py` grün.
- Kanonische Doku nachgezogen (2026-09-07): `mvp.md` §6.1 Fehlerbild Nachsatz/Prefix, Fit-Gap `mvp_stand_und_abgleich.md`, `mvp_implementation.md` §17, `memory_storage_and_offline.md`. Bewusst offen dokumentiert: 400-Zeichen-Recency im laufenden Dialogzug.
- Inventar der lokalen SQLite-Instanz (1 Profil, 2 Spaces, 17 Days/Entries, 379 Messages).
- Inventar der lokalen SQLite-Instanz (1 Profil, 2 Spaces, 17 Days/Entries, 379 Messages).
- Lokales Backup: `local-backups/kansho-20260907-055703.zip` (~10,1 MB, SHA256 `2D1E160F6DF95FA0C46B8F3FE69C1681827DE0DF304A2E955304A5C56CC7854F`). SQLite + 70 Medien, keine Secrets.
- `.\scripts\test-mvp.ps1` Exit 0 (isolierte Temp-Daten, Produktions-SQLite unangetastet).
- Technisches Handover und Heim-Setup-Auftrag angelegt.
## Noch auf dem Laptop, bevor die Instanz aufhört führend zu sein
### 1. Keine neuen Produktfeatures
Nicht auf dem Laptop beginnen oder zu Ende bauen:
- `docs/work_orders/debug_diagnostics_perfection.md`
- `docs/work_orders/dialogue_memory_next_slice_handover.md`
- `docs/architecture/functional/Idea_seconmd_sclide.md` (Slice-2-Konzeptbrief)
Diese Dateien versionieren, nicht ausführen.
### 2. Git nach Gitea
Ohne Push bleibt `e42f751 MVP 1.0` und der Opening-Fix nur auf dem Laptop.
Vorschlag zwei Commits (dieser Abschluss darf committen, wenn der Nutzer den Transfer schließt):
1. Opening-Fix + kanonische Opening-Doku (`mvp.md`, `mvp_implementation.md`, `mvp_stand_und_abgleich.md`, `memory_storage_and_offline.md`, Tests)
2. Transfer-Dokumentation, Work Orders, Slice-2-Brief
Remote: `origin` = `https://gitea.stommer.de/Lars/Kansho.git`, Branch `main`. Nach dem Push `git status` muss „up to date with origin/main“ zeigen.
`develop` auf dem Laptop **nicht** anlegen, solange kein Dev-Host existiert.
### 3. Daten und Secrets physisch kopieren
| Artefakt | Wohin | Nicht |
|---|---|---|
| `local-backups/kansho-20260907-055703.zip` | verschlüsselter Stick / NAS / Heimrechner | Git, Chat, Cloud-Klartext |
| `backend/.env` | separate Secret-Notiz oder Passwortmanager | Backup-Zip, Git |
| Login-Passwort des Admin-Profils | Kopf / Passwortmanager | Doku-Repo |
Nach dem Kopieren Checksumme des Zip notieren (`Get-FileHash`). Restore prüft Manifest-Checksummen zusätzlich.
Wenn nach dem Backup noch Dialoge auf dem Laptop laufen: neues Backup erzeugen und **dieses** Archiv mitnehmen.
### 4. Instanz stilllegen
- Uvicorn/Vite beenden.
- Keine parallelen Schreibzugriffe Laptop + Heimrechner auf denselben persönlichen Bestand.
- Laptop-Ordner als Archive behalten, bis Welle 1 zu Hause bestätigt ist.
### 5. Kurzer lokaler Smoke (optional, wenn Zeit)
Nur wenn der Opening-Fix committet werden soll und der Dev-Server ohnehin läuft:
- Neuen Journal Day im Space `Kroatien 2026` starten: erster Impuls darf keine abgeschlossene Vortags-Episode als offenen Faden nehmen.
- `.\scripts\test-mvp.ps1` rührt die Produktions-SQLite nicht an.
Kein Live-OpenRouter-Zwang für den Abschluss.
## Abnahme Laptop
- [ ] Opening-Fix und Transfer-Doku auf `origin/main`
- [ ] Backup-Zip außerhalb des Laptops
- [ ] `.env` außerhalb des Laptops
- [ ] Slice-2 / Debug / Memory-Aufträge nur versioniert, nicht implementiert
- [ ] Persönliche SQLite nicht in Git
## Abschlussbericht
Nennen: Commit-Hashes, Backup-Dateiname, ob nach dem Backup noch geschrieben wurde, was bewusst offen bleibt.

View File

@ -0,0 +1,31 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { formatApiError } from './api.js'
test('structured provider failure keeps reason and cost note', () => {
const message = formatApiError({
detail: {
code: 'provider_rejected',
message: 'generate-Provider hat die Anfrage abgelehnt (HTTP 402): Insufficient credits',
http_status: 402,
cost_report: { note: 'Beim Anbieter sind Kosten entstanden: Detect 0,0015 USD.' }
}
}, 502)
assert.match(message, /Insufficient credits/)
assert.match(message, /provider_rejected/)
assert.match(message, /Kosten entstanden/)
})
test('validation arrays are not the generic fallback', () => {
const message = formatApiError({
detail: [{ type: 'missing', loc: ['body', 'title'], msg: 'Field required' }]
}, 422)
assert.match(message, /Field required/)
assert.doesNotMatch(message, /^Anfrage fehlgeschlagen$/)
})
test('empty non-json gateway timeout explains the gap', () => {
const message = formatApiError(null, 504)
assert.match(message, /HTTP 504/)
assert.match(message, /Anbieter/)
})

View File

@ -1,19 +1,89 @@
function validationMessage(detail) {
if (!Array.isArray(detail) || !detail.length) return ''
const first = detail[0] || {}
const loc = Array.isArray(first.loc)
? first.loc.filter((part) => part !== 'body' && part !== 'query').join('.')
: ''
const msg = first.msg || first.message || ''
if (loc && msg) return `${msg} (${loc})`
return msg || ''
}
function costNote(detail) {
if (!detail || typeof detail !== 'object' || Array.isArray(detail)) return ''
return detail.cost_report?.note || detail.diagnostics?.cost_report?.note || ''
}
export function formatApiError(data, status, fallback = 'Anfrage fehlgeschlagen') {
const detail = data?.detail
let message = fallback
if (typeof detail === 'string' && detail.trim()) {
message = detail.trim()
} else if (Array.isArray(detail)) {
message = validationMessage(detail) || fallback
} else if (detail && typeof detail === 'object') {
const parts = []
if (detail.message) parts.push(detail.message)
if (
detail.provider_message
&& !parts.some((part) => String(part).includes(detail.provider_message))
) {
parts.push(detail.provider_message)
}
const extras = []
if (detail.code) extras.push(detail.code)
const httpStatus = detail.http_status || detail.diagnostics?.http_status
if (httpStatus && !parts.some((part) => String(part).includes(`HTTP ${httpStatus}`))) {
extras.push(`HTTP ${httpStatus}`)
}
message = parts.join(' ') || fallback
if (extras.length) message = `${message} (${extras.join(', ')})`
} else if (!data) {
if (status === 502 || status === 504) {
message = `Keine auswertbare Antwort vom Server (HTTP ${status}). Die Anfrage kann beim Anbieter trotzdem angekommen sein.`
} else if (status) {
message = `${fallback} (HTTP ${status}).`
}
}
const note = costNote(detail)
if (note && !message.includes(note)) message = `${message}\n${note}`
return message
}
function attachError(error, data, status) {
error.status = status
error.payload = data
error.code = data?.detail?.code
error.costReport = data?.detail?.cost_report || data?.detail?.diagnostics?.cost_report || null
error.diagnostics = data?.detail?.diagnostics || null
return error
}
export async function api(path, { token, method = 'GET', body } = {}) {
const headers = {}
if (token) headers['X-Auth-Token'] = token
if (body !== undefined) headers['Content-Type'] = 'application/json'
const response = await fetch(path, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined
})
let response
try {
response = await fetch(path, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined
})
} catch (err) {
const error = new Error(
'Keine Verbindung zum Server. Ob beim Anbieter Kosten entstanden sind, ist unbekannt.'
)
error.cause = err
throw error
}
const data = await response.json().catch(() => null)
if (!response.ok) {
const detail = data?.detail
const message = typeof detail === 'string' ? detail : (detail?.message || 'Anfrage fehlgeschlagen')
const error = new Error(message)
error.status = response.status
error.payload = data
const error = attachError(
new Error(formatApiError(data, response.status)),
data,
response.status
)
throw error
}
return data
@ -33,9 +103,7 @@ export async function apiDownload(path, { token, filename } = {}) {
const response = await fetch(path, { headers })
if (!response.ok) {
const data = await response.json().catch(() => null)
const detail = data?.detail
const message = typeof detail === 'string' ? detail : (detail?.message || 'Download fehlgeschlagen')
throw new Error(message)
throw attachError(new Error(formatApiError(data, response.status, 'Download fehlgeschlagen')), data, response.status)
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
@ -56,9 +124,7 @@ export async function apiUpload(path, { token, file, 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)
throw attachError(new Error(formatApiError(data, response.status, 'Upload fehlgeschlagen')), data, response.status)
}
return data
}

View File

@ -36,7 +36,7 @@ h2 { font-size: 1.05rem; margin: 1.1rem 0 0.45rem; }
p { line-height: 1.5; }
.muted, .lede { color: var(--muted); }
.lede { font-size: 0.9rem; }
.error { color: var(--danger); }
.error { color: var(--danger); white-space: pre-line; }
.centered { min-height: 100dvh; display: grid; place-items: center; }
.card {
@ -329,6 +329,17 @@ ul.stack { padding: 0; }
width: 100%;
min-height: 10rem;
}
.generation-style-context {
display: grid;
gap: 0.35rem;
margin: 0;
padding: 0.7rem 0.85rem;
border: 1px solid var(--line);
border-radius: 10px;
}
.generation-style-context legend {
padding: 0 0.3rem;
}
.generation-admin-preview {
margin-top: 1.4rem;
}

View File

@ -32,6 +32,34 @@ function styleLabel(meta) {
return `${meta.count} · ${meta.chars || 0} Zeichen${kinds ? ` · ${kinds}` : ''}${meta.dropped ? ' · budgetentfernt' : ''}`
}
function styleApplicationLabel(meta) {
if (!meta) return 'nicht verfügbar'
const requested = meta.requested || {}
const effective = meta.effective || {}
const name = [meta.label, meta.key ? `(${meta.key})` : '', meta.revision != null ? `Rev. ${meta.revision}` : '']
.filter(Boolean)
.join(' ')
const id = meta.id ? ` · ${meta.id}` : ''
const req = [
requested.include_core ? 'Core' : null,
requested.include_facet ? 'Facet' : null,
requested.include_traits ? 'Traits' : null,
requested.include_style_examples ? 'Beispiele' : null
].filter(Boolean)
const eff = `Core ${effective.include_core ? 'ja' : 'nein'}, Facet ${effective.include_facet ? 'ja' : 'nein'}, ${effective.trait_count ?? 0} Traits, ${effective.style_example_count ?? 0} Beispiele`
const omitted = Array.isArray(meta.omitted) && meta.omitted.length
? ` · ausgelassen: ${meta.omitted.map((item) => `${item.part} (${item.reason})`).join(', ')}`
: ''
const blocks = Array.isArray(effective.blocks)
? effective.blocks
.filter((item) => item.included)
.map((item) => `${item.key} ${item.chars || 0} Z. / ${item.estimated_tokens || 0} Tok.`)
.join('; ')
: ''
const predecessor = meta.cloned_from ? ` · Vorgänger ${meta.cloned_from}` : ''
return `${name || 'vorhanden'}${id}${predecessor} · angefordert: ${req.length ? req.join(', ') : 'keine'} · wirksam: ${eff}${blocks ? ` · ${blocks}` : ''}${omitted}`
}
function droppedLabel(dropped) {
if (!dropped || (Array.isArray(dropped) && dropped.length === 0)) return 'keine'
return Array.isArray(dropped) ? dropped.join(', ') : String(dropped)
@ -165,6 +193,8 @@ function BudgetMeta({ budget, trace, journal = false }) {
</dd>
<dt>Writing Profile</dt>
<dd>{profileLabel(trace?.writing_profile)}</dd>
<dt>Stilanwendung</dt>
<dd>{styleApplicationLabel(trace?.style_application)}</dd>
<dt>Stilquellen</dt>
<dd>{styleLabel(trace?.style_examples)}</dd>
<dt>Optionale Blöcke entfernt</dt>

View File

@ -16,6 +16,37 @@ const STATUS_LABEL = {
archived: 'Archiviert'
}
const STYLE_CONTEXT_FIELDS = [
{ key: 'include_core', label: 'Writing-Profile-Core' },
{ key: 'include_facet', label: 'Relevantes Facet' },
{ key: 'include_traits', label: 'Traits' },
{ key: 'include_style_examples', label: 'Stilbeispiele' }
]
function emptyStyleContext() {
return {
include_core: false,
include_facet: false,
include_traits: false,
include_style_examples: false
}
}
function styleContextOf(item) {
return {
...emptyStyleContext(),
...(item?.style_context || {})
}
}
function styleContextSummary(context) {
const data = styleContextOf({ style_context: context })
const parts = STYLE_CONTEXT_FIELDS
.filter((item) => data[item.key])
.map((item) => item.label)
return parts.length ? parts.join(', ') : 'keine'
}
function statusLine(item) {
const parts = [STATUS_LABEL[item.status] || item.status]
if (item.is_default) parts.push('Standard')
@ -108,7 +139,8 @@ export default function AdminGenerationPage() {
label: detail.label,
summary: detail.summary,
instruction: detail.instruction,
sort_order: Number(detail.sort_order || 0)
sort_order: Number(detail.sort_order || 0),
...(tab === 'voice' ? { style_context: styleContextOf(detail) } : {})
}
}),
'Entwurf angelegt.'
@ -126,7 +158,8 @@ export default function AdminGenerationPage() {
label: detail.label,
summary: detail.summary,
instruction: detail.instruction,
sort_order: Number(detail.sort_order || 0)
sort_order: Number(detail.sort_order || 0),
...(detail.slot === 'voice' || tab === 'voice' ? { style_context: styleContextOf(detail) } : {})
}
}),
'Entwurf gespeichert.'
@ -227,7 +260,9 @@ export default function AdminGenerationPage() {
instruction: '',
sort_order: (catalog?.slots?.[tab] || []).length,
status: 'draft',
revision: 1
revision: 1,
slot: tab,
style_context: emptyStyleContext()
})
}
@ -286,6 +321,9 @@ export default function AdminGenerationPage() {
<span className="muted">{statusLine(item)}</span>
</header>
<p>{item.summary || 'Keine Kurzbeschreibung.'}</p>
{item.slot === 'voice' || tab === 'voice' ? (
<p className="muted">Stilquellen: {styleContextSummary(item.style_context)}</p>
) : null}
<div className="row-actions">
<button type="button" className="ghost" onClick={() => openItem(item.id)}>Öffnen</button>
<button type="button" className="ghost" onClick={() => cloneItem(item.id)}>Klonen</button>
@ -338,12 +376,38 @@ export default function AdminGenerationPage() {
required
/>
</label>
{(detail.slot === 'voice' || tab === 'voice') && (
<fieldset className="generation-style-context" disabled={!editable}>
<legend>Stilquellen für diese Ausprägung</legend>
<p className="muted">
Nur freigegebene Bestandteile gelangen in den Prompt. Keine Gewichtungen.
Promptanweisung und Stilkontext werden gemeinsam versioniert.
</p>
{STYLE_CONTEXT_FIELDS.map((field) => (
<label key={field.key} className="check">
<input
type="checkbox"
checked={Boolean(styleContextOf(detail)[field.key])}
onChange={(event) => setDetail((current) => ({
...current,
style_context: {
...styleContextOf(current),
[field.key]: event.target.checked
}
}))}
disabled={!editable}
/>
{field.label}
</label>
))}
</fieldset>
)}
<details>
<summary>Technische Herkunft</summary>
<p className="muted">
ID: {detail.id || 'neu'}
{detail.revision != null ? ` · Revision ${detail.revision}` : ''}
{detail.cloned_from ? ` · geklont von ${detail.cloned_from}` : ''}
{detail.cloned_from ? ` · Vorgänger ${detail.cloned_from}` : ''}
{detail.seed_revision ? ` · Systemstand ${detail.seed_revision}` : ''}
</p>
</details>

View File

@ -18,6 +18,16 @@ const SLOT_SELECTS = [
{ slot: 'narrative', key: 'narrative_id', title: 'Erzählgestaltung' }
]
function styleSourcesLine(option) {
const context = option?.style_context || {}
const parts = []
if (context.include_core) parts.push('Core')
if (context.include_facet) parts.push('Facet')
if (context.include_traits) parts.push('Traits')
if (context.include_style_examples) parts.push('Stilbeispiele')
return parts.length ? `Stilquellen: ${parts.join(', ')}` : 'Stilquellen: keine'
}
const EMPTY_SELECTION = {
transformation_id: '',
detail_id: '',
@ -344,6 +354,9 @@ export default function JournalDayPage() {
))}
</select>
{selected?.summary ? <small className="muted policy-summary">{selected.summary}</small> : null}
{item.slot === 'voice' && selected ? (
<small className="muted policy-summary">{styleSourcesLine(selected)}</small>
) : null}
{stale ? <small className="error">Diese Ausprägung ist nicht mehr wählbar.</small> : null}
</label>
)

View File

@ -582,6 +582,9 @@ export default function SettingsPage() {
API-Analyse
</button>
</div>
{reviewBusy && (
<p className="muted">Analyse läuft Detect und Generate können länger als eine Minute dauern.</p>
)}
{reviewPrompt && (
<>
<p className="muted">Diesen Prompt in einen bestehenden Chat mit deinen Texten einfügen. Das JSON-Ergebnis danach oben einfügen.</p>

View File

@ -24,8 +24,8 @@ export default defineConfig({
globPatterns: ['**/*.{js,css,html,svg}'],
runtimeCaching: [{
urlPattern: /^\/api\//,
handler: 'NetworkFirst',
options: { cacheName: 'api-cache', expiration: { maxEntries: 50 } }
handler: 'NetworkOnly',
options: { cacheName: 'api-cache' }
}]
}
})
@ -33,6 +33,12 @@ export default defineConfig({
server: {
port: 5188,
strictPort: true,
proxy: { '/api': 'http://127.0.0.1:8018' }
proxy: {
'/api': {
target: 'http://127.0.0.1:8018',
timeout: 600000,
proxyTimeout: 600000
}
}
}
})

View File

@ -102,6 +102,16 @@ try {
}
}
Invoke-Step -Name "frontend api.error.test.js" -Action {
Push-Location (Join-Path $root "frontend")
try {
$node = Get-Command node -ErrorAction Stop
& $node.Source --test "src/api.error.test.js"
} finally {
Pop-Location
}
}
Invoke-Step -Name "frontend production build" -Action {
Push-Location (Join-Path $root "frontend")
try {