This commit is contained in:
Lars 2026-08-29 20:28:59 +02:00
parent be11f82f84
commit e42f751fdc
47 changed files with 3359 additions and 301 deletions

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", "purpose": "journal_generate",
"max_instruction_chars": 1000, "max_instruction_chars": 1000,
"max_label_chars": 80, "max_label_chars": 80,
@ -79,31 +79,120 @@
{ {
"id": "journal-generate-voice-neutral", "id": "journal-generate-voice-neutral",
"guideline_key": "neutral", "guideline_key": "neutral",
"label": "neutral", "label": "Ohne persönliches Profil",
"summary": "Neutraler Journalstil, Writing Profile nur als leise Tendenz.", "summary": "Natürlicher, neutraler Journalstil ohne Writing Profile und ohne Stilbeispiele.",
"instruction": "Schreibe in einem neutralen Journalstil. Das Writing Profile höchstens als leise Tendenz." "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", "id": "journal-generate-voice-light",
"guideline_key": "light", "guideline_key": "light",
"label": "dezent", "label": "Persönliche Stimme dezent",
"summary": "Rhythmus und Wortwahl des Writing Profiles zurückhaltend.", "summary": "Zurückhaltende Orientierung am Schreibkern, ohne Facet, Traits oder Beispiele.",
"instruction": "Nimm Rhythmus und Wortwahl des Writing Profiles zurückhaltend auf." "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", "id": "journal-generate-voice-noticeable",
"guideline_key": "noticeable", "guideline_key": "noticeable",
"label": "spürbar", "label": "spürbar",
"summary": "Writing Profile spürbar, ohne in den Vordergrund zu treten.", "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", "id": "journal-generate-voice-clear",
"guideline_key": "clear", "guideline_key": "clear",
"label": "deutlich", "label": "Persönliche Stimme deutlich",
"summary": "Rhythmus, Wortwahl und Reflexionsdichte des Writing Profiles 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.", "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", "category": "mvp",
"prompt_type": "base", "prompt_type": "base",
"required_feature": "ai_calls", "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", "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-27-journal-mixed-sources-v1" "seed_revision": "2026-08-29-voice-legacy-immutable-v1"
}, },
{ {
"id": "mvp-journal-reconstruct", "id": "mvp-journal-reconstruct",

View File

@ -75,6 +75,7 @@ def build_internal_context(
existing_text: str = "", existing_text: str = "",
reconstruction: str = "", reconstruction: str = "",
day_spec: dict | None = None, day_spec: dict | None = None,
writing_profile: str | None = None,
style_examples: str = "", style_examples: str = "",
transformation_instructions: str = "", transformation_instructions: str = "",
detail_instructions: str = "", detail_instructions: str = "",
@ -151,9 +152,12 @@ def build_internal_context(
items.append({"type": "space_recent_sources", "sources": recent_sources}) items.append({"type": "space_recent_sources", "sources": recent_sources})
if purpose == "journal_generate": if purpose == "journal_generate":
if writing_profile is None:
from writing_profile_store import compile_task_brief 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": "writing_profile", "compiled_brief": brief})
items.append({"type": "style_examples", "body": style_examples or ""}) items.append({"type": "style_examples", "body": style_examples or ""})
items.append({"type": "transformation_instructions", "text": transformation_instructions or ""}) items.append({"type": "transformation_instructions", "text": transformation_instructions or ""})

View File

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

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
import uuid import uuid
@ -36,6 +37,8 @@ LAYER_FOR_PURPOSE = {
"journal_reconstruct": "journalrekonstruktion", "journal_reconstruct": "journalrekonstruktion",
"profile_review": "profilreview", "profile_review": "profilreview",
} }
SANITIZE_MAX_DEPTH = 80
LOGGER = logging.getLogger("kansho.debug")
def _now() -> str: def _now() -> str:
@ -78,18 +81,26 @@ def should_persist(profile_id: str | None) -> bool:
return persist_enabled() and _is_admin(profile_id) return persist_enabled() and _is_admin(profile_id)
def sanitize(value: Any) -> Any: 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): if isinstance(value, dict):
cleaned = {} cleaned = {}
for key, item in value.items(): for key, item in value.items():
if key in FORBIDDEN_KEYS: if key in FORBIDDEN_KEYS:
continue continue
cleaned[key] = sanitize(item) cleaned[key] = sanitize(item, _stack=stack, _depth=_depth + 1)
return cleaned return cleaned
if isinstance(value, list): return [sanitize(item, _stack=stack, _depth=_depth + 1) for item in value]
return [sanitize(item) for item in value] finally:
if isinstance(value, tuple): stack.discard(ident)
return [sanitize(item) for item in value]
return value return value
@ -251,12 +262,41 @@ def persist_engine_error(
journal_day_id: str | None = None, journal_day_id: str | None = None,
extra: dict | None = None, extra: dict | None = None,
) -> str | None: ) -> str | None:
try:
diag = dict(getattr(exc, "diagnostics", None) or {}) diag = dict(getattr(exc, "diagnostics", None) or {})
merged = dict(extra or {}) merged = dict(extra or {})
merged.setdefault("code", getattr(exc, "code", None)) merged.setdefault("code", getattr(exc, "code", None))
merged.setdefault("message", getattr(exc, "message", None) or str(exc)) merged.setdefault("message", getattr(exc, "message", None) or str(exc))
if diag.get("log") is not None: log = diag.get("log")
merged.setdefault("run_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( return persist_step(
profile_id, profile_id,
purpose=purpose, purpose=purpose,
@ -266,9 +306,15 @@ def persist_engine_error(
subject_id=subject_id, subject_id=subject_id,
conversation_id=conversation_id, conversation_id=conversation_id,
journal_day_id=journal_day_id, journal_day_id=journal_day_id,
trace=diag.get("trace"), trace=trace or None,
extra=merged, 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: 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 from __future__ import annotations
import contextvars
import json import json
import re import re
import time import time
@ -41,6 +42,23 @@ ERROR_DETECT_INCOMPLETE = "detect_incomplete"
ERROR_DETECT_INVALID = "detect_invalid_output" ERROR_DETECT_INVALID = "detect_invalid_output"
ERROR_DETECT_TRUNCATED = "detect_truncated" ERROR_DETECT_TRUNCATED = "detect_truncated"
ERROR_DETECT_CHUNK = "detect_chunk_failed" 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( DETECT_DIALOGUE_FALLBACK_CODES = frozenset(
{ {
ERROR_DETECT_INVALID, ERROR_DETECT_INVALID,
@ -117,8 +135,14 @@ class DetectionStats:
completion_tokens: int = 0 completion_tokens: int = 0
total_tokens: int = 0 total_tokens: int = 0
cost: float = 0.0 cost: float = 0.0
cost_known: bool = False
detect_ms: int = 0 detect_ms: int = 0
abort_reason: str | None = None 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]: def public(self) -> dict[str, Any]:
payload = { payload = {
@ -138,11 +162,23 @@ class DetectionStats:
"detect_prompt_tokens": self.prompt_tokens, "detect_prompt_tokens": self.prompt_tokens,
"detect_completion_tokens": self.completion_tokens, "detect_completion_tokens": self.completion_tokens,
"detect_total_tokens": self.total_tokens, "detect_total_tokens": self.total_tokens,
"detect_cost": self.cost,
"detect_ms": self.detect_ms, "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: if self.abort_reason:
payload["abort_reason"] = 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 return payload
@ -153,32 +189,51 @@ class DetectionOutcome:
local_identities: list[dict] local_identities: list[dict]
_injected_spans: list[dict] | None = None _injected_spans: contextvars.ContextVar[list[dict] | None] = contextvars.ContextVar(
_injected_fail: DetectError | None = None "kansho_detect_spans",
_injected_truncated: bool = False 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: def install_test_spans(spans: list[dict] | None) -> None:
"""Test-only span injection. Production never calls this.""" """Test-only span injection. Production never calls this."""
global _injected_spans _injected_spans.set(None if spans is None else [dict(item) for item in spans])
_injected_spans = None if spans is None else [dict(item) for item in spans]
def install_test_detect_failure(error: DetectError | None) -> None: def install_test_detect_failure(error: DetectError | None) -> None:
global _injected_fail _injected_fail.set(error)
_injected_fail = error
def install_test_truncated(flag: bool = True) -> None: def install_test_truncated(flag: bool = True) -> None:
global _injected_truncated _injected_truncated.set(bool(flag))
_injected_truncated = 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: def reset_detect_test_hooks() -> None:
global _injected_spans, _injected_fail, _injected_truncated _injected_spans.set(None)
_injected_spans = None _injected_fail.set(None)
_injected_fail = None _injected_truncated.set(False)
_injected_truncated = False _injected_script.set(None)
def uses_llm_detect(config) -> bool: def uses_llm_detect(config) -> bool:
@ -295,25 +350,41 @@ def _contract_fake_spans(text: str) -> list[dict]:
return found 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: def _parse_detect_json(raw: str) -> dict:
text = (raw or "").strip() text = (raw or "").strip()
if text.startswith("```"): if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text, flags=re.IGNORECASE | re.DOTALL).strip() text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text, flags=re.IGNORECASE | re.DOTALL).strip()
match = JSON_BLOCK.search(text) match = JSON_BLOCK.search(text)
if not match: 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: try:
data = json.loads(match.group(0)) data = json.loads(match.group(0))
except json.JSONDecodeError as exc: 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): 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 extra = set(data.keys()) - ALLOWED_ROOT_FIELDS
if extra: 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") items = data.get("entities")
if not isinstance(items, list): 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 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: def validate_detected_entity(item: Any, chunk_text: str, chunk_index: int) -> DetectedSpan | None:
if not isinstance(item, dict): 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 extra = set(item.keys()) - ALLOWED_ENTITY_FIELDS
if extra: 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()) missing = ALLOWED_ENTITY_FIELDS - set(item.keys())
if missing: if missing:
raise DetectError(ERROR_DETECT_INVALID, "Detect-Entity ist unvollständig.") raise _contract_error("Detect-Entity ist unvollständig.", violation="missing_fields")
try: try:
start = int(item["start"]) start = int(item["start"])
except (TypeError, ValueError): except (TypeError, ValueError):
@ -381,17 +452,26 @@ def validate_detected_entity(item: Any, chunk_text: str, chunk_index: int) -> De
end = 0 end = 0
text = item.get("text") text = item.get("text")
if not isinstance(text, str): 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(): if not text.strip():
return None raise _contract_error("Detect-text ist leer.", violation="invalid_text")
if "token" in item or "placeholder" in item: if "token" in item or "placeholder" in item:
raise DetectError(ERROR_DETECT_INVALID, "Detect darf keine Tokens festlegen.") raise _contract_error("Detect darf keine Tokens festlegen.", violation="extra_fields")
kind = normalize_entity_type(item.get("entity_type"), default="") raw_type = item.get("entity_type")
kind = normalize_entity_type(raw_type if isinstance(raw_type, str) else "", default="")
if not kind: 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) grounded = _ground_span(chunk_text, start, end, text)
if not grounded: if not grounded:
return None raise _contract_error(
"Detect-Offsets sind nicht im Chunk verankert.",
violation="unusable_offsets",
)
left, right, local_text = grounded left, right, local_text = grounded
return DetectedSpan(start=left, end=right, text=local_text, entity_type=kind, chunk_index=chunk_index) 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: def _add_usage(stats: DetectionStats, usage: dict | None) -> None:
data = usage or {} data = usage or {}
if data.get("prompt_tokens") is not None:
stats.prompt_tokens += int(data.get("prompt_tokens") or 0) 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) 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) 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: try:
stats.cost += float(data.get("cost") or 0) stats.cost += float(extra)
stats.cost_known = True
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
def _llm_chunk(config, excerpt: str) -> ChatResult: def _llm_chunk(config, excerpt: str, *, schema_retry: bool = False) -> ChatResult:
prompt = resolve_template( prompt = resolve_template(
_detect_prompt()["template"], _detect_prompt()["template"],
{"source_text": excerpt, "known_labels": ""}, {"source_text": excerpt, "known_labels": ""},
) )
if schema_retry:
prompt = f"{prompt.rstrip()}\n\n{SCHEMA_RETRY_HINT}"
return complete_chat( return complete_chat(
config, config,
[{"role": "user", "content": prompt}], [{"role": "user", "content": prompt}],
@ -447,6 +537,7 @@ def _entities_from_result(result: ChatResult, chunk_text: str, chunk_index: int)
raise DetectError( raise DetectError(
ERROR_DETECT_TRUNCATED, ERROR_DETECT_TRUNCATED,
"Detect-Ausgabe wurde abgeschnitten. Generate wird nicht freigegeben.", "Detect-Ausgabe wurde abgeschnitten. Generate wird nicht freigegeben.",
diagnostics={"contract_violation": "truncated"},
) )
data = _parse_detect_json(result.content or "") data = _parse_detect_json(result.content or "")
spans: list[DetectedSpan] = [] spans: list[DetectedSpan] = []
@ -571,18 +662,192 @@ def _merge_confirmed_safety_net(text: str, mappings: list[dict], profile_id: str
return mappings + extra, hits 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: def detect_personal_egress(profile_id: str | None, source_text: str) -> DetectionOutcome:
"""Full semantic detection of the personal generate-egress. Fail closed if incomplete.""" """Full semantic detection of the personal generate-egress. Fail closed if incomplete."""
started = time.perf_counter() started = time.perf_counter()
stats = DetectionStats(source_chars=len(source_text or "")) stats = DetectionStats(source_chars=len(source_text or ""))
if _injected_fail is not None: injected_fail = _injected_fail.get()
stats.abort_reason = _injected_fail.code if injected_fail is not None:
stats.abort_reason = injected_fail.code
stats.detect_ms = int((time.perf_counter() - started) * 1000) stats.detect_ms = int((time.perf_counter() - started) * 1000)
stats.detect_passes = 0
raise DetectError( raise DetectError(
_injected_fail.code, injected_fail.code,
_injected_fail.message, injected_fail.message,
_injected_fail.status_code, injected_fail.status_code,
{**(_injected_fail.diagnostics or {}), **stats.public()}, {**(injected_fail.diagnostics or {}), **stats.public()},
) )
config = detect_provider() config = detect_provider()
if not config: 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 "") chunks = split_detect_chunks(source_text or "")
stats.chunk_count = len(chunks) stats.chunk_count = len(chunks)
collected: list[DetectedSpan] = [] collected: list[DetectedSpan] = []
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: try:
for index, (offset, chunk_text) in enumerate(chunks): collected = _run_detection_pass(
if _injected_truncated: config,
raise DetectError( chunks,
ERROR_DETECT_TRUNCATED, attempt=attempt,
"Detect-Ausgabe wurde abgeschnitten. Generate wird nicht freigegeben.", schema_retry=attempt > 1,
stats=pass_stats,
) )
if _injected_spans is not None: pass_stats.full_detection_coverage = True
raw_items = [ pass_stats.detect_ms = int((time.perf_counter() - pass_started) * 1000)
item _merge_pass_stats(stats, pass_stats)
for item in _injected_spans stats.detect_attempts.append(
if int(item.get("start") or 0) >= offset _pass_snapshot(pass_stats, attempt=attempt, coverage=True)
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" last_error = None
elif config.mode == "fake": break
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: 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.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.full_detection_coverage = False
stats.detect_passes = attempt
stats.detect_ms = int((time.perf_counter() - started) * 1000) stats.detect_ms = int((time.perf_counter() - started) * 1000)
raise DetectError( raise DetectError(
exc.code, exc.code,
exc.message, exc.message,
exc.status_code, exc.status_code,
{**(exc.diagnostics or {}), **stats.public()}, {**inner, **stats.public()},
) from exc ) from exc
if stats.chunks_ok != stats.chunk_count: if last_error is not None:
stats.abort_reason = ERROR_DETECT_INCOMPLETE stats.detect_passes = len(stats.detect_attempts)
stats.detect_ms = int((time.perf_counter() - started) * 1000) stats.detect_ms = int((time.perf_counter() - started) * 1000)
raise DetectError( raise DetectError(
ERROR_DETECT_INCOMPLETE, last_error.code,
"Detection hat nicht alle Chunks geprüft. Generate wird nicht freigegeben.", last_error.message,
diagnostics=stats.public(), last_error.status_code,
{**(last_error.diagnostics or {}), **stats.public()},
) )
stats.detect_passes = len(stats.detect_attempts)
merged = _dedupe_spans(collected) merged = _dedupe_spans(collected)
confirmed = list_confirmed_identities(profile_id) if profile_id else [] confirmed = list_confirmed_identities(profile_id) if profile_id else []
mappings, request_local, confirmed_from_spans = _assign_request_tokens(merged, confirmed) mappings, request_local, confirmed_from_spans = _assign_request_tokens(merged, confirmed)

View File

@ -11,6 +11,11 @@ import re
from difflib import SequenceMatcher from difflib import SequenceMatcher
from journal_body import plain_text 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_schema import is_meta_style_text, recency_weight
from writing_profile_store import ( from writing_profile_store import (
clip_field, clip_field,
@ -23,7 +28,7 @@ STYLE_EXAMPLE_MAX = 2
STYLE_EXAMPLE_CHARS = 900 STYLE_EXAMPLE_CHARS = 900
MIN_EXAMPLE_CHARS = 40 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 = ( EMPTY_STYLE_EXAMPLES = (
"Keine historischen Stilbeispiele. Es gilt nur WRITING_PROFILE " "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: def writing_profile_trace(
"""Presence metadata only. No profile text, no labels.""" 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 ( from writing_profile_store import (
NEUTRAL_JOURNAL_STYLE, NEUTRAL_JOURNAL_STYLE,
compile_task_brief, compile_task_brief,
@ -93,6 +108,7 @@ def writing_profile_trace(profile_id: str) -> dict:
confirmed = has_confirmed_profile(profile_id) confirmed = has_confirmed_profile(profile_id)
profile = get_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() core = ((profile.get("core") or {}).get("value") or "").strip()
facet = next( facet = next(
( (
@ -107,15 +123,30 @@ def writing_profile_trace(profile_id: str) -> dict:
for item in profile.get("traits") or [] for item in profile.get("traits") or []
if item.get("status") == "active" and (item.get("statement") or "").strip() 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 { return {
"confirmed": confirmed, "confirmed": confirmed,
"present": bool(confirmed and brief and brief != NEUTRAL_JOURNAL_STYLE), "present": present,
"neutral_fallback": brief == NEUTRAL_JOURNAL_STYLE, "neutral_fallback": brief == NEUTRAL_JOURNAL_STYLE,
"has_core": bool(confirmed and core), "has_core": has_core,
"has_facet": bool(confirmed and facet), "has_facet": has_facet,
"trait_count": len(traits) if confirmed else 0, "trait_count": int(trait_count or 0),
"brief_chars": len(brief or ""), "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() 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: def _content_digest(text: str) -> str:
body = re.sub(r"\s+", " ", plain_text(text or "")).strip().lower() body = re.sub(r"\s+", " ", plain_text(text or "")).strip().lower()
return hashlib.sha256(body.encode("utf-8")).hexdigest() 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 identity_store import is_maskable_label, list_mappings, mapping_spellings
from journal_body import clean_title from journal_body import clean_title
from journal_editorial import ( from journal_editorial import (
format_style_examples, compile_journal_style_application,
incomplete_syntax_markers, incomplete_syntax_markers,
lexical_similarity, lexical_similarity,
narration_sources_text, narration_sources_text,
select_journal_style_examples, style_application_trace,
style_example_diagnostics, style_example_diagnostics,
writing_profile_trace, writing_profile_trace,
) )
@ -26,6 +26,7 @@ from journal_generation_policy import (
draft_snapshot, draft_snapshot,
mark_guidelines_used, mark_guidelines_used,
policy_trace, policy_trace,
prompt_without_orphaned_style_example_rule,
resolve_run_selection, resolve_run_selection,
) )
from journal_reconstruct import ( from journal_reconstruct import (
@ -45,6 +46,7 @@ from prompt_budget import (
from privacy_gateway import ( from privacy_gateway import (
GENERIC_PLACEHOLDER_INNER, GENERIC_PLACEHOLDER_INNER,
canonical_token, canonical_token,
compact_diagnostics,
identity_label_pattern, identity_label_pattern,
identity_occurrence_count, identity_occurrence_count,
is_identity_mention, is_identity_mention,
@ -201,6 +203,8 @@ def _compose_journal_trace(
narrate_trace["writing_profile"] = profile_meta narrate_trace["writing_profile"] = profile_meta
narrate_trace["style_examples"] = {**style_meta, "dropped": "style_examples" in dropped} narrate_trace["style_examples"] = {**style_meta, "dropped": "style_examples" in dropped}
narrate_trace["dropped_optional_blocks"] = 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 {} diag = narrate_result.get("diagnostics") or {}
if diag.get("generate_ms") is not None: if diag.get("generate_ms") is not None:
narrate_trace["generate_ms"] = diag.get("generate_ms") narrate_trace["generate_ms"] = diag.get("generate_ms")
@ -221,8 +225,9 @@ def _compose_journal_trace(
"prompt_revision": narrate_trace.get("prompt_revision"), "prompt_revision": narrate_trace.get("prompt_revision"),
"writing_profile": profile_meta, "writing_profile": profile_meta,
"style_examples": narrate_trace.get("style_examples"), "style_examples": narrate_trace.get("style_examples"),
"style_application": narrate_trace.get("style_application") or (extra or {}).get("style_application"),
"dropped_optional_blocks": dropped, "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], "stages": [reconstruct_trace, narrate_trace],
} }
@ -264,11 +269,17 @@ def _raise_budget(exc: JournalBudgetError) -> None:
def _stage_trace(result: dict, fallback_purpose: str) -> dict: def _stage_trace(result: dict, fallback_purpose: str) -> dict:
trace = dict(result.get("trace") or {}) source = result.get("trace")
if result.get("diagnostics"): trace = dict(source) if isinstance(source, dict) else {}
trace["budget"] = result.get("diagnostics") budget_src = result.get("diagnostics")
elif not trace.get("budget"): 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 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 trace["purpose"] = trace.get("purpose") or fallback_purpose
return trace return trace
@ -333,7 +344,11 @@ def pack_narration_context(
candidate = dict(assembled) candidate = dict(assembled)
candidate["style_examples"] = examples candidate["style_examples"] = examples
candidate["existing_text"] = existing 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: try:
assert_input_fits(budget, rendered) assert_input_fits(budget, rendered)
if not examples and style_examples: if not examples and style_examples:
@ -464,12 +479,20 @@ def _generate_draft(
source=persist_meta["source"], source=persist_meta["source"],
remembered=persist_meta["remembered"], remembered=persist_meta["remembered"],
) )
profile_meta = writing_profile_trace(profile_id) style_bundle = compile_journal_style_application(
style_example_rows = select_journal_style_examples(
profile_id, profile_id,
compiled_policy.style_context,
exclude_dates=[day.get("calendar_date") or ""], 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) style_meta = style_example_diagnostics(style_example_rows)
run_log: list[dict] = [] run_log: list[dict] = []
_event( _event(
@ -503,6 +526,7 @@ def _generate_draft(
existing_text=existing_text, existing_text=existing_text,
conversation_id=selected[0] if selected else None, conversation_id=selected[0] if selected else None,
reconstruction=narration_sources_text(reconstruction), reconstruction=narration_sources_text(reconstruction),
writing_profile=writing_profile,
style_examples=style_examples, style_examples=style_examples,
transformation_instructions=compiled_policy.instructions["transformation_instructions"], transformation_instructions=compiled_policy.instructions["transformation_instructions"],
detail_instructions=compiled_policy.instructions["detail_instructions"], detail_instructions=compiled_policy.instructions["detail_instructions"],
@ -523,11 +547,21 @@ def _generate_draft(
_raise_budget(exc) _raise_budget(exc)
if dropped: if dropped:
_event(run_log, "journal_generate", "budget_pack", dropped=",".join(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) mappings = list_mappings(profile_id)
preview_title, preview_body = _local_narration(reconstruction) preview_title, preview_body = _local_narration(reconstruction)
source_preview = f"{preview_title}\n\n{preview_body}".strip() source_preview = f"{preview_title}\n\n{preview_body}".strip()
narrate_result: dict = {} narrate_result: dict = {}
try: 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) rendered_preview = preview_prompt(narrate_prompt, assembled)
assert_template_resolved(rendered_preview.get("rendered") or "") assert_template_resolved(rendered_preview.get("rendered") or "")
narrate_result = execute_prompt( narrate_result = execute_prompt(
@ -575,6 +609,7 @@ def _generate_draft(
"abort_reason": unattested, "abort_reason": unattested,
"source_preview": source_preview, "source_preview": source_preview,
"generation_selection": policy_meta, "generation_selection": policy_meta,
"style_application": style_app,
}, },
) )
_reject_generation( _reject_generation(
@ -622,6 +657,7 @@ def _generate_draft(
"abort_reason": exc.code, "abort_reason": exc.code,
"source_preview": source_preview, "source_preview": source_preview,
"generation_selection": policy_meta, "generation_selection": policy_meta,
"style_application": style_app,
}, },
) )
diag["log"] = run_log diag["log"] = run_log
@ -700,6 +736,7 @@ def _generate_draft(
"lexical_similarity": lexical_similarity("\n".join(source_user), body), "lexical_similarity": lexical_similarity("\n".join(source_user), body),
"incomplete_syntax": incomplete_syntax_markers(body), "incomplete_syntax": incomplete_syntax_markers(body),
"generation_selection": policy_meta, "generation_selection": policy_meta,
"style_application": style_app,
"stored_title": title, "stored_title": title,
"stored_body": body, "stored_body": body,
"reply": stored, "reply": stored,

View File

@ -63,6 +63,46 @@ MAX_LABEL_CHARS = 80
MAX_SUMMARY_CHARS = 160 MAX_SUMMARY_CHARS = 160
KEY_RE = re.compile(r"^[a-z][a-z0-9_]{0,40}$") KEY_RE = re.compile(r"^[a-z][a-z0-9_]{0,40}$")
SEED_PATH = Path(__file__).resolve().parent / "config" / "generation_instructions.seed.json" 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 = ( PUBLIC_FIELDS = (
"id", "id",
"purpose", "purpose",
@ -108,6 +148,8 @@ class CompiledPolicy:
revisions: dict[str, int] revisions: dict[str, int]
instructions: dict[str, str] instructions: dict[str, str]
seed_revision: str seed_revision: str
style_context: dict[str, bool]
cloned_from: dict[str, str]
def load_seed_document() -> dict: 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 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]: def _limits() -> tuple[int, int, int]:
seed = load_seed_document() seed = load_seed_document()
return ( 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["revision"] = int(item.get("revision") or 1)
payload["sort_order"] = int(item.get("sort_order") or 0) payload["sort_order"] = int(item.get("sort_order") or 0)
payload["guideline_key"] = item.get("guideline_key") or "" 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: if include_instruction:
payload["instruction"] = item.get("instruction") or "" payload["instruction"] = item.get("instruction") or ""
payload["seed_id"] = item.get("seed_id") 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"], "summary": item["summary"],
"revision": item["revision"], "revision": item["revision"],
"is_default": item["is_default"], "is_default": item["is_default"],
"style_context": item.get("style_context") or {},
} }
for item in items for item in items
if item["slot"] == slot 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}, revisions={slot: int(chosen[slot].get("revision") or 1) for slot in USER_SLOTS},
instructions=instructions, instructions=instructions,
seed_revision=revision, 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), "labels": dict(compiled.labels),
"revisions": dict(compiled.revisions), "revisions": dict(compiled.revisions),
"seed_revision": compiled.seed_revision, "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"], "key": compiled.keys["voice"],
"label": compiled.labels["voice"], "label": compiled.labels["voice"],
"revision": compiled.revisions["voice"], "revision": compiled.revisions["voice"],
"cloned_from": compiled.cloned_from.get("voice") or "",
"style_context": dict(compiled.style_context),
}, },
"narrative": { "narrative": {
"id": compiled.ids["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: def load_selection(profile_id: str) -> dict[str, str] | None:
with get_db() as conn: with get_db() as conn:
row = row_to_dict( 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() max_instruction, max_label, max_summary = _limits()
style_context = normalize_style_context(
raw.get("style_context"),
slot=slot,
required=False,
)
return { return {
"guideline_key": _clean_key(raw.get("guideline_key") or raw.get("key")), "guideline_key": _clean_key(raw.get("guideline_key") or raw.get("key")),
"label": _clean_text(raw.get("label"), "label", max_chars=max_label), "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), "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), "instruction": _clean_text(raw.get("instruction"), "instruction", max_chars=max_instruction),
"sort_order": int(raw.get("sort_order") or 0), "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: def create_guideline(purpose: str, slot: str, body: dict) -> dict:
if slot not in ALLOWED_SLOTS: if slot not in ALLOWED_SLOTS:
raise CatalogError("Unbekannter Slot.", code="invalid_generation_guideline", status_code=400) 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()) guideline_id = str(uuid.uuid4())
with get_db() as conn: with get_db() as conn:
count = conn.execute( count = conn.execute(
@ -521,11 +694,11 @@ def create_guideline(purpose: str, slot: str, body: dict) -> dict:
conn.execute( conn.execute(
""" """
INSERT INTO generation_guidelines ( 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, status, revision, cloned_from, is_default, is_system_seed, seed_id, seed_revision,
created, updated created, updated
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, NULL, 0, 0, '', '', datetime('now'), datetime('now')) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, NULL, 0, 0, '', '', datetime('now'), datetime('now'))
""", """,
( (
guideline_id, guideline_id,
@ -535,6 +708,7 @@ def create_guideline(purpose: str, slot: str, body: dict) -> dict:
fields["label"], fields["label"],
fields["summary"], fields["summary"],
fields["instruction"], fields["instruction"],
fields["style_context_json"],
fields["sort_order"] if body.get("sort_order") is not None else int(count) + 1, fields["sort_order"] if body.get("sort_order") is not None else int(count) + 1,
STATUS_DRAFT, STATUS_DRAFT,
), ),
@ -557,11 +731,11 @@ def clone_guideline(guideline_id: str) -> dict:
conn.execute( conn.execute(
""" """
INSERT INTO generation_guidelines ( 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, status, revision, cloned_from, is_default, is_system_seed, seed_id, seed_revision,
created, updated created, updated
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, '', ?, datetime('now'), datetime('now')) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, '', ?, datetime('now'), datetime('now'))
""", """,
( (
new_id, new_id,
@ -571,6 +745,14 @@ def clone_guideline(guideline_id: str) -> dict:
current["label"], current["label"],
current["summary"], current["summary"],
current["instruction"], 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), int(current.get("sort_order") or 0),
STATUS_DRAFT, STATUS_DRAFT,
int(current.get("revision") or 1) + 1, int(current.get("revision") or 1) + 1,
@ -591,13 +773,20 @@ def update_guideline(guideline_id: str, body: dict) -> dict:
code="guideline_immutable", code="guideline_immutable",
status_code=409, 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: with get_db() as conn:
conn.execute( conn.execute(
""" """
UPDATE generation_guidelines UPDATE generation_guidelines
SET guideline_key = ?, label = ?, summary = ?, instruction = ?, sort_order = ?, SET guideline_key = ?, label = ?, summary = ?, instruction = ?, style_context_json = ?,
updated = datetime('now') sort_order = ?, updated = datetime('now')
WHERE id = ? WHERE id = ?
""", """,
( (
@ -605,6 +794,7 @@ def update_guideline(guideline_id: str, body: dict) -> dict:
fields["label"], fields["label"],
fields["summary"], fields["summary"],
fields["instruction"], fields["instruction"],
fields["style_context_json"],
fields["sort_order"], fields["sort_order"],
guideline_id, 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("label"), "label", max_chars=max_label)
_clean_text(current.get("summary"), "summary", max_chars=max_summary, allow_empty=True) _clean_text(current.get("summary"), "summary", max_chars=max_summary, allow_empty=True)
_clean_text(current.get("instruction"), "instruction", max_chars=max_instruction) _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: with get_db() as conn:
conn.execute( conn.execute(
"UPDATE generation_guidelines SET status = ?, updated = datetime('now') WHERE id = ?", "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 "", "summary": item.get("summary") or "",
"instruction": item.get("instruction") or "", "instruction": item.get("instruction") or "",
"sort_order": index, "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) compiled = compile_selection(selection, purpose=purpose)
prompt = load_active_prompt("mvp.journal_generate") prompt = load_active_prompt("mvp.journal_generate")
assert_journal_prompt_contract(prompt.get("template") or "") 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( rendered = preview_prompt(
prompt, prompt,
{ {
**compiled.instructions, **compiled.instructions,
"writing_profile": "(nicht enthalten)", "writing_profile": "(nicht enthalten)",
"style_examples": "(nicht enthalten)", "style_examples": "(Stilbeispiele)" if include_examples else "",
"reconstruction": "(nicht enthalten)", "reconstruction": "(nicht enthalten)",
"existing_text": "", "existing_text": "",
}, },
@ -746,6 +952,8 @@ def preview_selection(selection: dict[str, str], *, purpose: str = PURPOSE_JOURN
"keys": dict(compiled.keys), "keys": dict(compiled.keys),
"labels": dict(compiled.labels), "labels": dict(compiled.labels),
"revisions": dict(compiled.revisions), "revisions": dict(compiled.revisions),
"cloned_from": dict(compiled.cloned_from),
"style_context": dict(compiled.style_context),
}, },
"seed_revision": compiled.seed_revision, "seed_revision": compiled.seed_revision,
} }
@ -758,6 +966,7 @@ def _seed_rows(seed: dict, purpose: str) -> list[tuple]:
for slot in ALLOWED_SLOTS: for slot in ALLOWED_SLOTS:
variants = (slots.get(slot) or {}).get("variants") or [] variants = (slots.get(slot) or {}).get("variants") or []
for index, item in enumerate(variants): for index, item in enumerate(variants):
context = normalize_style_context(item.get("style_context") or {}, slot=slot, required=False)
rows.append( rows.append(
( (
item.get("id") or str(uuid.uuid4()), 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("label") or "",
item.get("summary") or "", item.get("summary") or "",
item.get("instruction") or "", item.get("instruction") or "",
style_context_json(context, slot=slot),
index, index,
STATUS_ACTIVE, STATUS_ACTIVE,
1, 1,
@ -779,9 +989,132 @@ def _seed_rows(seed: dict, purpose: str) -> list[tuple]:
return rows return rows
def seed_generation_instructions(conn) -> None: def _semantic_signature(item: dict | None = None, *, seed_row: tuple | None = None) -> tuple:
"""Insert missing seed guidelines. Never overwrite published or admin-created rows.""" if seed_row is not None:
seed = load_seed_document() 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 purpose = seed.get("purpose") or PURPOSE_JOURNAL
conn.execute( conn.execute(
""" """
@ -791,34 +1124,42 @@ def seed_generation_instructions(conn) -> None:
""", """,
(STATUS_ARCHIVED, purpose, LEGACY_SOURCE_MODE_SLOT, STATUS_ARCHIVED), (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): for row in _seed_rows(seed, purpose):
row_id = row[0] by_id, by_seed_id = _catalog_maps(conn, purpose)
seed_id = row[12] all_rows = list(by_id.values())
if row_id in existing or seed_id in existing_seed_ids: 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 continue
conn.execute( if current.get("status") != STATUS_ACTIVE:
""" continue
INSERT INTO generation_guidelines ( if _semantic_signature(current) == _semantic_signature(seed_row=row):
id, purpose, slot, guideline_key, label, summary, instruction, sort_order, continue
status, revision, cloned_from, is_default, is_system_seed, seed_id, seed_revision, successor = _find_matching_successor(all_rows, current["id"], row)
created, updated if successor:
) if row[11] and successor.get("status") == STATUS_ACTIVE:
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, datetime('now'), datetime('now')) _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, row,
guideline_id=successor_id,
cloned_from=current["id"],
revision=next_revision,
is_default=int(row[11] or 0),
) )
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: def backfill_missing_settings(conn) -> None:

View File

@ -85,6 +85,20 @@ COMPACT_DIAGNOSTIC_KEYS = (
"detect_cost", "detect_cost",
"detect_ms", "detect_ms",
"generate_called", "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" 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]: def compact_diagnostics(data: dict[str, Any] | None) -> dict[str, Any]:
payload = {} payload = {}
for key in COMPACT_DIAGNOSTIC_KEYS: for key in COMPACT_DIAGNOSTIC_KEYS:
if data and key in data and data[key] is not None: if data and key in data and data[key] is not None:
payload[key] = data[key] payload[key] = _snapshot_plain(data[key])
return payload 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( _LOG_FORBIDDEN_KEYS = frozenset(
{ {
"local_label", "local_label",
@ -227,6 +386,7 @@ def _raise_with_log(
diagnostics["purpose"] = purpose diagnostics["purpose"] = purpose
if extra: if extra:
diagnostics.update({key: value for key, value in extra.items() if value is not None}) 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 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 + '"}' 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: def complete_model(messages: list[dict], policy: dict[str, Any]) -> ChatResult:
config = generate_provider() config = generate_provider()
if not config: if not config:
@ -851,7 +1019,7 @@ def complete_model(messages: list[dict], policy: dict[str, Any]) -> ChatResult:
return complete_chat( return complete_chat(
config, config,
messages, 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"), max_tokens=policy.get("max_tokens"),
disable_context_compression=bool(policy.get("disable_context_compression")), disable_context_compression=bool(policy.get("disable_context_compression")),
) )
@ -911,7 +1079,13 @@ def complete(request: GatewayRequest) -> GatewayResult:
exc.code, exc.code,
user_detect_message(exc.code, exc.message), user_detect_message(exc.code, exc.message),
exc.status_code, 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 ) from exc
_log_event( _log_event(
events, events,
@ -945,6 +1119,7 @@ def complete(request: GatewayRequest) -> GatewayResult:
diagnostics.update(summary) diagnostics.update(summary)
diagnostics.update({key: value for key, value in detect_stats.items() if value is not None}) diagnostics.update({key: value for key, value in detect_stats.items() if value is not None})
diagnostics["generate_called"] = False diagnostics["generate_called"] = False
diagnostics["generate_requested"] = False
if request.purpose in JOURNAL_PURPOSES: if request.purpose in JOURNAL_PURPOSES:
if budget is None: if budget is None:
_log_event(events, started, "budget", status="error", code="model_metadata_unknown") _log_event(events, started, "budget", status="error", code="model_metadata_unknown")
@ -1079,6 +1254,8 @@ def complete(request: GatewayRequest) -> GatewayResult:
purpose=request.purpose, purpose=request.purpose,
model=config.model if config else None, 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) chat = complete_model([{"role": "user", "content": masked}], model_policy)
usages.append(chat.usage or {}) usages.append(chat.usage or {})
diagnostics["generate_called"] = True diagnostics["generate_called"] = True
@ -1198,6 +1375,7 @@ def public_trace(trace: dict | None) -> dict | None:
"detect_ms": trace.get("detect_ms"), "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_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_called": trace.get("generate_called"),
"generate_requested": trace.get("generate_requested"),
"mapping_count": trace.get("mapping_count"), "mapping_count": trace.get("mapping_count"),
"available_mapping_count": trace.get("available_mapping_count"), "available_mapping_count": trace.get("available_mapping_count"),
"active_mapping_count": trace.get("active_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 "", subject_id=review_id or "",
) )
raise raise
try:
parsed = parse_result(result.get("content") or "") 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"): if not parsed.get("mode"):
parsed["mode"] = package.get("mode") or "" parsed["mode"] = package.get("mode") or ""
staged = _stage_proposal(profile_id, parsed, review_id=review_id, package=package) 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.""" """Two independently configured OpenAI-compatible providers: generate vs detect."""
from __future__ import annotations from __future__ import annotations
import json
import os import os
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
@ -28,6 +29,53 @@ class ProviderError(Exception):
self.diagnostics = diagnostics or {} 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) @dataclass(frozen=True)
class ProviderConfig: class ProviderConfig:
role: str role: str
@ -193,23 +241,45 @@ def complete_chat(
headers["X-Title"] = "Kansho" headers["X-Title"] = "Kansho"
try: try:
response = httpx.post(config.url, json=payload, headers=headers, timeout=timeout) 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: 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: if response.status_code >= 400:
body = "" body = ""
try: try:
body = response.text or "" body = response.text or ""
except Exception: except Exception:
body = "" body = ""
diagnostics = provider_error_diagnostics(
response.status_code,
body,
extra={"context_compression": compression, "generate_requested": True},
)
if CONTEXT_LENGTH_REJECT.search(body): if CONTEXT_LENGTH_REJECT.search(body):
raise ProviderError( raise ProviderError(
"provider_context_length_rejected", "provider_context_length_rejected",
"Der Anbieter hat die Anfrage wegen der Kontextlänge abgelehnt. " "Der Anbieter hat die Anfrage wegen der Kontextlänge abgelehnt. "
"Kanshō hat den Tagesdialog nicht automatisch gekürzt.", "Kanshō hat den Tagesdialog nicht automatisch gekürzt.",
502, 502,
diagnostics={"http_status": response.status_code, "context_compression": compression}, diagnostics=diagnostics,
)
raise ProviderError(
"provider_rejected",
_reject_message(config.role, response.status_code, diagnostics),
502,
diagnostics=diagnostics,
) )
raise ProviderError("provider_rejected", f"{config.role}-Provider hat die Anfrage abgelehnt.")
data = response.json() data = response.json()
try: try:
content = data["choices"][0]["message"]["content"] content = data["choices"][0]["message"]["content"]

View File

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

View File

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

View File

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

View File

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

View File

@ -71,7 +71,6 @@ def test_prompt_contract() -> None:
text = prompt.get("template") or "" text = prompt.get("template") or ""
expect("INHALTSTREUE" in text, "prompt keeps hard content rules") expect("INHALTSTREUE" in text, "prompt keeps hard content rules")
expect("CURRENT_DAY_SOURCES" in text, "prompt labels current-day facts") 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("WRITING_PROFILE" in text, "prompt labels the writing profile")
expect("{{transformation_instructions}}" in text, "prompt injects compiled transformation instructions") expect("{{transformation_instructions}}" in text, "prompt injects compiled transformation instructions")
expect("{{detail_instructions}}" in text, "prompt injects compiled detail instructions") expect("{{detail_instructions}}" in text, "prompt injects compiled detail instructions")
@ -253,8 +252,9 @@ def main() -> None:
intern = intern_of(gen.json()) intern = intern_of(gen.json())
expect(MIXED_SOURCES_INSTRUCTION in intern, "unified mixed-source instruction reaches the model") expect(MIXED_SOURCES_INSTRUCTION in intern, "unified mixed-source instruction reaches the model")
expect("CURRENT_DAY_SOURCES" in intern, "day facts are labeled") expect("CURRENT_DAY_SOURCES" in intern, "day facts are labeled")
expect("STYLE_EXAMPLES" in intern, "style examples are labeled") expect("\nSTYLE_EXAMPLES\n" not in intern, "default voice does not attach a style-example section")
expect("WRITING_PROFILE" in intern, "writing profile is labeled") 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("Ü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("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") 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("{{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("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("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 = { required_classes = {
"already_narrative_with_errors", "already_narrative_with_errors",

View File

@ -60,7 +60,6 @@ HARD_FACTS = (
"Plan und Vollzug", "Plan und Vollzug",
"Verneinungen", "Verneinungen",
"Unsicherheiten", "Unsicherheiten",
"STYLE_EXAMPLES dienen ausschließlich als Stilreferenz",
"[[PERSON:01]]", "[[PERSON:01]]",
) )
BANNED_CODE_PHRASES = ( BANNED_CODE_PHRASES = (
@ -101,6 +100,11 @@ REQUIRED_KEYS = {
("voice", "light"), ("voice", "light"),
("voice", "noticeable"), ("voice", "noticeable"),
("voice", "clear"), ("voice", "clear"),
("voice", "with_examples"),
("voice", "legacy_neutral"),
("voice", "legacy_light"),
("voice", "legacy_noticeable"),
("voice", "legacy_clear"),
("narrative", "chronicle"), ("narrative", "chronicle"),
("narrative", "structured"), ("narrative", "structured"),
("narrative", "weighted"), ("narrative", "weighted"),
@ -209,7 +213,9 @@ def test_runtime_has_no_numeric_policy() -> None:
continue continue
expect(not has_token(text, token), f"{path.name} has no {token}") expect(not has_token(text, token), f"{path.name} has no {token}")
day = (FRONTEND / "src" / "pages" / "JournalDayPage.jsx").read_text(encoding="utf-8") 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("policy-slider" not in day, "journal page has no slider class")
expect("SLOT_SELECTS" in day, "journal page names the four selects") expect("SLOT_SELECTS" in day, "journal page names the four selects")
expect(day.count("slot:") >= 4, "journal page has four independent slots") 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") 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("<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(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("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("Quellenmodus" not in admin, "admin has no source-mode management")
expect("source_mode" not in admin, "admin source has no source_mode slot") 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("Chunks technisch verarbeitet" in trace_ui, "trace separates technical chunk coverage")
expect("Semantische Identitätserkennung" in trace_ui, "trace names semantic uncertainty") expect("Semantische Identitätserkennung" in trace_ui, "trace names semantic uncertainty")
expect("nicht garantiert" in trace_ui, "trace does not claim semantic completeness") 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("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") 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", "default narrative instruction matches the seed",
) )
expect(defaults.seed_revision == SEED["seed_revision"], "compiled policy names the seed revision") 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") 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()) low = compile_selection(low_ids())
high = compile_selection(high_ids()) high = compile_selection(high_ids())
expect(low.keys["transformation"] == "correction", "low transformation is named") 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_mode}}" not in text, "prompt has no editorial_mode placeholder")
expect("{{editorial_instructions}}" not in text, "prompt has no editorial_instructions 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(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: for fact in HARD_FACTS:
expect(fact in text, f"hard fact rule remains: {fact}") expect(fact in text, f"hard fact rule remains: {fact}")
expect("ich gieng zum laden" not in text, "no synthetic prose example") expect("ich gieng zum laden" not in text, "no synthetic prose example")
expect("nachbarhund im garten" not in text, "no synthetic notes 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("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: with get_db() as conn:
row = conn.execute( row = conn.execute(
"SELECT seed_revision, template, default_template FROM ai_prompts WHERE slug = ?", "SELECT seed_revision, template, default_template FROM ai_prompts WHERE slug = ?",
("mvp.journal_generate",), ("mvp.journal_generate",),
).fetchone() ).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(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") expect("source_mode" not in (SEED.get("slots") or {}), "source modes stay out of the seed")
@ -1019,7 +1045,7 @@ def main() -> None:
).fetchone() ).fetchone()
expect("{{transformation_instructions}}" in (row["default_template"] or ""), "default template still tracks mixed sources") 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(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( readable = snapshot_summary(
{ {

View File

@ -51,7 +51,7 @@ def test_active_prompt_contract() -> None:
text = prompt.get("template") or "" text = prompt.get("template") or ""
expect("INHALTSTREUE" in text, "active prompt keeps hard content rules") expect("INHALTSTREUE" in text, "active prompt keeps hard content rules")
expect("CURRENT_DAY_SOURCES" in text, "active prompt labels current-day facts") 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("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("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") 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 = ?", "SELECT seed_revision, template, default_template FROM ai_prompts WHERE slug = ?",
("mvp.journal_generate",), ("mvp.journal_generate",),
).fetchone() ).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") expect(row["template"] == row["default_template"], "untouched install uses the seeded template")
@ -313,7 +313,7 @@ def main() -> None:
("mvp.journal_generate",), ("mvp.journal_generate",),
).fetchone() ).fetchone()
expect("INHALTSTREUE" in (row["default_template"] or ""), "default template still tracks the seed") 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.") print("journal narration tests passed.")

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, "WRITING_PROFILE" in narrate_intern or "Neutraler Journalstil" in narrate_intern or "Core:" in narrate_intern,
"A style brief reaches generate", "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] 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("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") 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": if stage.get("purpose") == "journal_generate":
intern_i = stage.get("intern") or "" intern_i = stage.get("intern") or ""
intern_i = intern_i or ((gen_i.json().get("trace") or {}).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("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") expect("knapp" in imported.json()["compiled_brief"], "I stored brief still lists the import")
profile = client.get("/api/journal/writing-profile", headers=headers).json() profile = client.get("/api/journal/writing-profile", headers=headers).json()

View File

@ -134,12 +134,15 @@ def main() -> None:
0, 0,
) )
expect(span.text == "Anna" and span.start == start, "valid span matches the local substring") expect(span.text == "Anna" and span.start == start, "valid span matches the local substring")
relocated = validate_detected_entity( expect(
validate_detected_entity(
{"start": 0, "end": 4, "text": "Anna", "entity_type": "PERSON"}, {"start": 0, "end": 4, "text": "Anna", "entity_type": "PERSON"},
sample, sample,
0, 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( expect(
validate_detected_entity( validate_detected_entity(
{"start": -1, "end": 4, "text": "Anna", "entity_type": "PERSON"}, {"start": -1, "end": 4, "text": "Anna", "entity_type": "PERSON"},
@ -149,15 +152,20 @@ def main() -> None:
== start, == start,
"negative offsets still ground when the word exists", "negative offsets still ground when the word exists",
) )
expect( try:
validate_detected_entity( validate_detected_entity(
{"start": 0, "end": 4, "text": "Nein", "entity_type": "PERSON"}, {"start": 0, "end": 4, "text": "Nein", "entity_type": "PERSON"},
sample, sample,
0, 0,
) )
is None, missing_word = False
"a word that is not in the chunk is skipped", 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( expect(
validate_detected_entity( validate_detected_entity(
{"start": "x", "end": 4, "text": "Anna", "entity_type": "PERSON"}, {"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. """Compact confirmed style brief. Historical texts are selected separately.
Unconfirmed profiles do not become a style authority. Journal adapters attach 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) profile = get_profile(profile_id)
if task != "journal_generate": if task != "journal_generate":
core = (profile.get("core") or {}).get("value") or "" core = (profile.get("core") or {}).get("value") or ""
return _clip_field(core, TASK_BRIEF_MAX_CHARS) 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): if not has_confirmed_profile(profile_id):
return NEUTRAL_JOURNAL_STYLE return NEUTRAL_JOURNAL_STYLE
parts: list[str] = [] parts: list[str] = []
core_text = ((profile.get("core") or {}).get("value") or "").strip() core_text = ((profile.get("core") or {}).get("value") or "").strip() if include_core else ""
if core_text: if include_core and core_text:
clipped = _clip_field(core_text, TASK_BRIEF_CORE_CHARS) clipped = _clip_field(core_text, TASK_BRIEF_CORE_CHARS)
if clipped: if clipped:
parts.append("Core: " + clipped) parts.append("Core: " + clipped)
@ -1214,13 +1225,14 @@ def compile_task_brief(profile_id: str, task: str = "journal_generate") -> str:
), ),
None, None,
) )
facet_text = ((facet or {}).get("value") or "").strip() facet_text = ((facet or {}).get("value") or "").strip() if include_facet else ""
if facet_text and _norm_overlap(facet_text, core_text) < 0.8: if include_facet and facet_text and _norm_overlap(facet_text, core_text) < 0.8:
clipped = _clip_field(facet_text, TASK_BRIEF_FACET_CHARS) clipped = _clip_field(facet_text, TASK_BRIEF_FACET_CHARS)
if clipped: if clipped:
parts.append("Autobiografisches Journaling (Facet-Delta): " + 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] used_statements = [core_text, facet_text]
trait_lines: list[str] = []
for item in selected: for item in selected:
statement = (item.get("statement") or "").strip() statement = (item.get("statement") or "").strip()
if any(_norm_overlap(statement, previous) >= 0.85 for previous in used_statements if previous): 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) clipped = _clip_field(statement, TASK_BRIEF_TRAIT_CHARS)
if not clipped: if not clipped:
continue 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) used_statements.append(statement)
parts.extend(trait_lines)
brief = "\n".join(part for part in parts if part).strip() brief = "\n".join(part for part in parts if part).strip()
while len(brief) > TASK_BRIEF_MAX_CHARS and parts: while len(brief) > TASK_BRIEF_MAX_CHARS and parts:
last = parts.pop() last = parts.pop()
@ -1240,7 +1253,9 @@ def compile_task_brief(profile_id: str, task: str = "journal_generate") -> str:
brief = "\n".join(parts).strip() brief = "\n".join(parts).strip()
break break
brief = "\n".join(parts).strip() brief = "\n".join(parts).strip()
if include_core and include_facet and include_traits:
return brief or NEUTRAL_JOURNAL_STYLE return brief or NEUTRAL_JOURNAL_STYLE
return brief
def has_facets(profile_id: str) -> bool: def has_facets(profile_id: str) -> bool:

View File

@ -439,6 +439,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. 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 ## 8.3 Generierungsprinzip
@ -557,6 +559,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. 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 # 10. Journal Editor

View File

@ -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. | | 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** | | | 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 ## 5.7 Editor, Versionen, Nachschlagen, Screens
| Anforderung | Stand | Befund | | Anforderung | Stand | Befund |

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-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 ## 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-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) ## 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-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 ## 5. Entscheidungsstand
| Thema | Stand | Status | | 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. - 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-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-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) ## 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. - 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>)` - 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 - 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`. - 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. - Beide Profile: `GET /profiles/export`, `POST /profiles/restore` (`kind: kansho.profiles`). Journal-Backup ist `local_backup.py`, nicht dieser Profil-Export.

View File

@ -134,5 +134,5 @@ Vor Vereinfachungen die Invariantenliste in `technische_zielarchitektur.md` §3.
## 5. Code-Gerüst ## 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.

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-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. 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. 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. - `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. - `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-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.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.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. - `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.

View File

@ -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` | | 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` | | 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, 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` | | Request-scoped Maskierungsmanifest / Pre-Egress | `backend/tests/test_privacy_manifest.py` |
| Journal-Narration (Faktentreue, nicht Wortlaut) | `backend/tests/test_journal_narration.py` | | Journal-Narration (Faktentreue, nicht Wortlaut) | `backend/tests/test_journal_narration.py` |
| Journal-Editorial Modes, Profil, Stilreferenzen | `backend/tests/test_journal_editorial.py` | | Journal-Editorial Modes, Profil, Stilreferenzen | `backend/tests/test_journal_editorial.py` |
| Journal-Generation-Policy | `backend/tests/test_journal_generation_policy.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` | | Journal-Eval-Vertrag (kein Live-Qualitätsbeleg) | `backend/tests/test_journal_eval.py` |
| Shape ohne Pronomenheuristik | `backend/tests/test_journal_shape.py` | | Shape ohne Pronomenheuristik | `backend/tests/test_journal_shape.py` |
| Writing Profile | `backend/tests/test_writing_profile.py` | | Writing Profile | `backend/tests/test_writing_profile.py` |
| Profile Governance | `backend/tests/test_profile_governance.py` | | Profile Governance | `backend/tests/test_profile_governance.py` |
| Profile Review Pipeline | `backend/tests/test_profile_review.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` | | Journal-Budget / zweistufige Generierung | `backend/tests/test_journal_budget.py` |
| Persistierte Admin-Testspur | `backend/tests/test_debug_persist.py` | | Persistierte Admin-Testspur | `backend/tests/test_debug_persist.py` |
| Lokales Backup/Restore | `backend/tests/test_local_backup.py` | | Lokales Backup/Restore | `backend/tests/test_local_backup.py` |
@ -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. 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. 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-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`. 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. 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. 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. **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"}]} {"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 ### 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. 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). 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 ### 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 ## 10. Offene Fragen

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 } = {}) { export async function api(path, { token, method = 'GET', body } = {}) {
const headers = {} const headers = {}
if (token) headers['X-Auth-Token'] = token if (token) headers['X-Auth-Token'] = token
if (body !== undefined) headers['Content-Type'] = 'application/json' if (body !== undefined) headers['Content-Type'] = 'application/json'
const response = await fetch(path, { let response
try {
response = await fetch(path, {
method, method,
headers, headers,
body: body !== undefined ? JSON.stringify(body) : undefined 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) const data = await response.json().catch(() => null)
if (!response.ok) { if (!response.ok) {
const detail = data?.detail const error = attachError(
const message = typeof detail === 'string' ? detail : (detail?.message || 'Anfrage fehlgeschlagen') new Error(formatApiError(data, response.status)),
const error = new Error(message) data,
error.status = response.status response.status
error.payload = data )
throw error throw error
} }
return data return data
@ -33,9 +103,7 @@ export async function apiDownload(path, { token, filename } = {}) {
const response = await fetch(path, { headers }) const response = await fetch(path, { headers })
if (!response.ok) { if (!response.ok) {
const data = await response.json().catch(() => null) const data = await response.json().catch(() => null)
const detail = data?.detail throw attachError(new Error(formatApiError(data, response.status, 'Download fehlgeschlagen')), data, response.status)
const message = typeof detail === 'string' ? detail : (detail?.message || 'Download fehlgeschlagen')
throw new Error(message)
} }
const blob = await response.blob() const blob = await response.blob()
const url = URL.createObjectURL(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 response = await fetch(path, { method: 'POST', headers, body })
const data = await response.json().catch(() => null) const data = await response.json().catch(() => null)
if (!response.ok) { if (!response.ok) {
const detail = data?.detail throw attachError(new Error(formatApiError(data, response.status, 'Upload fehlgeschlagen')), data, response.status)
const message = typeof detail === 'string' ? detail : (detail?.message || 'Upload fehlgeschlagen')
throw new Error(message)
} }
return data return data
} }

View File

@ -36,7 +36,7 @@ h2 { font-size: 1.05rem; margin: 1.1rem 0 0.45rem; }
p { line-height: 1.5; } p { line-height: 1.5; }
.muted, .lede { color: var(--muted); } .muted, .lede { color: var(--muted); }
.lede { font-size: 0.9rem; } .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; } .centered { min-height: 100dvh; display: grid; place-items: center; }
.card { .card {
@ -329,6 +329,17 @@ ul.stack { padding: 0; }
width: 100%; width: 100%;
min-height: 10rem; 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 { .generation-admin-preview {
margin-top: 1.4rem; 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' : ''}` 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) { function droppedLabel(dropped) {
if (!dropped || (Array.isArray(dropped) && dropped.length === 0)) return 'keine' if (!dropped || (Array.isArray(dropped) && dropped.length === 0)) return 'keine'
return Array.isArray(dropped) ? dropped.join(', ') : String(dropped) return Array.isArray(dropped) ? dropped.join(', ') : String(dropped)
@ -165,6 +193,8 @@ function BudgetMeta({ budget, trace, journal = false }) {
</dd> </dd>
<dt>Writing Profile</dt> <dt>Writing Profile</dt>
<dd>{profileLabel(trace?.writing_profile)}</dd> <dd>{profileLabel(trace?.writing_profile)}</dd>
<dt>Stilanwendung</dt>
<dd>{styleApplicationLabel(trace?.style_application)}</dd>
<dt>Stilquellen</dt> <dt>Stilquellen</dt>
<dd>{styleLabel(trace?.style_examples)}</dd> <dd>{styleLabel(trace?.style_examples)}</dd>
<dt>Optionale Blöcke entfernt</dt> <dt>Optionale Blöcke entfernt</dt>

View File

@ -16,6 +16,37 @@ const STATUS_LABEL = {
archived: 'Archiviert' 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) { function statusLine(item) {
const parts = [STATUS_LABEL[item.status] || item.status] const parts = [STATUS_LABEL[item.status] || item.status]
if (item.is_default) parts.push('Standard') if (item.is_default) parts.push('Standard')
@ -108,7 +139,8 @@ export default function AdminGenerationPage() {
label: detail.label, label: detail.label,
summary: detail.summary, summary: detail.summary,
instruction: detail.instruction, 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.' 'Entwurf angelegt.'
@ -126,7 +158,8 @@ export default function AdminGenerationPage() {
label: detail.label, label: detail.label,
summary: detail.summary, summary: detail.summary,
instruction: detail.instruction, 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.' 'Entwurf gespeichert.'
@ -227,7 +260,9 @@ export default function AdminGenerationPage() {
instruction: '', instruction: '',
sort_order: (catalog?.slots?.[tab] || []).length, sort_order: (catalog?.slots?.[tab] || []).length,
status: 'draft', 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> <span className="muted">{statusLine(item)}</span>
</header> </header>
<p>{item.summary || 'Keine Kurzbeschreibung.'}</p> <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"> <div className="row-actions">
<button type="button" className="ghost" onClick={() => openItem(item.id)}>Öffnen</button> <button type="button" className="ghost" onClick={() => openItem(item.id)}>Öffnen</button>
<button type="button" className="ghost" onClick={() => cloneItem(item.id)}>Klonen</button> <button type="button" className="ghost" onClick={() => cloneItem(item.id)}>Klonen</button>
@ -338,12 +376,38 @@ export default function AdminGenerationPage() {
required required
/> />
</label> </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> <details>
<summary>Technische Herkunft</summary> <summary>Technische Herkunft</summary>
<p className="muted"> <p className="muted">
ID: {detail.id || 'neu'} ID: {detail.id || 'neu'}
{detail.revision != null ? ` · Revision ${detail.revision}` : ''} {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}` : ''} {detail.seed_revision ? ` · Systemstand ${detail.seed_revision}` : ''}
</p> </p>
</details> </details>

View File

@ -18,6 +18,16 @@ const SLOT_SELECTS = [
{ slot: 'narrative', key: 'narrative_id', title: 'Erzählgestaltung' } { 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 = { const EMPTY_SELECTION = {
transformation_id: '', transformation_id: '',
detail_id: '', detail_id: '',
@ -344,6 +354,9 @@ export default function JournalDayPage() {
))} ))}
</select> </select>
{selected?.summary ? <small className="muted policy-summary">{selected.summary}</small> : null} {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} {stale ? <small className="error">Diese Ausprägung ist nicht mehr wählbar.</small> : null}
</label> </label>
) )

View File

@ -582,6 +582,9 @@ export default function SettingsPage() {
API-Analyse API-Analyse
</button> </button>
</div> </div>
{reviewBusy && (
<p className="muted">Analyse läuft Detect und Generate können länger als eine Minute dauern.</p>
)}
{reviewPrompt && ( {reviewPrompt && (
<> <>
<p className="muted">Diesen Prompt in einen bestehenden Chat mit deinen Texten einfügen. Das JSON-Ergebnis danach oben einfügen.</p> <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}'], globPatterns: ['**/*.{js,css,html,svg}'],
runtimeCaching: [{ runtimeCaching: [{
urlPattern: /^\/api\//, urlPattern: /^\/api\//,
handler: 'NetworkFirst', handler: 'NetworkOnly',
options: { cacheName: 'api-cache', expiration: { maxEntries: 50 } } options: { cacheName: 'api-cache' }
}] }]
} }
}) })
@ -33,6 +33,12 @@ export default defineConfig({
server: { server: {
port: 5188, port: 5188,
strictPort: true, 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 { Invoke-Step -Name "frontend production build" -Action {
Push-Location (Join-Path $root "frontend") Push-Location (Join-Path $root "frontend")
try { try {