From 4f37991ad12363a4a97ae98b0af59ca817f433c5 Mon Sep 17 00:00:00 2001
From: Lars
Date: Fri, 28 Aug 2026 08:40:28 +0200
Subject: [PATCH] MVP 0.94
---
.../config/generation_instructions.seed.json | 145 +++
backend/config/prompts.seed.json | 6 +-
backend/context_builder.py | 36 +-
backend/db.py | 11 +
backend/engine.py | 1 +
backend/entity_detect.py | 31 +-
backend/entity_detect_eval.py | 196 +++-
backend/identity_store.py | 24 +
backend/journal_editorial.py | 124 ++-
backend/journal_eval.py | 354 +++++--
backend/journal_generate.py | 339 ++++--
backend/journal_generation_policy.py | 864 ++++++++++++++++
backend/journal_store.py | 20 +-
backend/main.py | 3 +-
backend/placeholder_mvp.py | 28 +-
backend/privacy_gateway.py | 398 ++++++--
backend/routers/generation_instructions.py | 151 +++
backend/routers/journal.py | 10 +
backend/schema.sql | 31 +
backend/tests/test_frame.py | 6 +-
backend/tests/test_journal_budget.py | 52 +-
backend/tests/test_journal_editorial.py | 192 +++-
backend/tests/test_journal_eval.py | 58 +-
.../tests/test_journal_generation_policy.py | 961 ++++++++++++++++++
backend/tests/test_journal_narration.py | 55 +-
backend/tests/test_mvp_journal.py | 2 +-
backend/tests/test_privacy_detect.py | 9 +-
backend/tests/test_privacy_detect_eval.py | 19 +-
backend/tests/test_privacy_manifest.py | 142 ++-
.../tests/test_privacy_response_integrity.py | 353 +++++++
docs/architecture/functional/guardrails.md | 2 +
.../functional/mvp_freeze_candidate.md | 7 +-
.../writing_profile_and_journaling.md | 20 +-
.../technical/admin_diagnostics.md | 8 +
.../architecture/technical/backend_and_api.md | 28 +-
.../technical/documentation_index.md | 4 +-
.../technical/memory_storage_and_offline.md | 6 +
.../technical/mvp_implementation.md | 25 +-
.../architecture/technical/privacy_gateway.md | 24 +-
frontend/src/App.jsx | 2 +
frontend/src/app.css | 79 ++
frontend/src/components/CallTrace.jsx | 119 ++-
frontend/src/components/RunLogPopup.jsx | 20 +-
frontend/src/config/adminNav.js | 1 +
frontend/src/pages/AdminGenerationPage.jsx | 399 ++++++++
frontend/src/pages/AdminHomePage.jsx | 2 +-
frontend/src/pages/JournalDayPage.jsx | 88 +-
frontend/src/pages/JournalEditorPage.jsx | 4 +
48 files changed, 4919 insertions(+), 540 deletions(-)
create mode 100644 backend/config/generation_instructions.seed.json
create mode 100644 backend/journal_generation_policy.py
create mode 100644 backend/routers/generation_instructions.py
create mode 100644 backend/tests/test_journal_generation_policy.py
create mode 100644 backend/tests/test_privacy_response_integrity.py
create mode 100644 frontend/src/pages/AdminGenerationPage.jsx
diff --git a/backend/config/generation_instructions.seed.json b/backend/config/generation_instructions.seed.json
new file mode 100644
index 0000000..442f5b6
--- /dev/null
+++ b/backend/config/generation_instructions.seed.json
@@ -0,0 +1,145 @@
+{
+ "seed_revision": "2026-08-27-generation-guidelines-v1",
+ "purpose": "journal_generate",
+ "max_instruction_chars": 1000,
+ "max_label_chars": 80,
+ "max_summary_chars": 160,
+ "slots": {
+ "transformation": {
+ "instruction_key": "transformation_instructions",
+ "variants": [
+ {
+ "id": "journal-generate-transformation-correction",
+ "guideline_key": "correction",
+ "label": "behutsam",
+ "summary": "Nur Korrektur von Sprache und Zeichensetzung.",
+ "instruction": "Korrigiere nur Rechtschreibung, Grammatik und Zeichensetzung. Behalte Satzbau, Reihenfolge und Formulierungen."
+ },
+ {
+ "id": "journal-generate-transformation-copyedit",
+ "guideline_key": "copyedit",
+ "label": "redaktionell",
+ "summary": "Glättet Übergänge und behält die vorhandene Struktur.",
+ "instruction": "Glätte holprige Stellen und verbessere Übergänge. Behalte die vorhandene Satz- und Absatzstruktur weitgehend."
+ },
+ {
+ "id": "journal-generate-transformation-reshape",
+ "guideline_key": "reshape",
+ "label": "spürbar",
+ "summary": "Formuliert schwache Stellen neu, wenn die Lesbarkeit gewinnt.",
+ "instruction": "Formuliere schwache oder fehlerhafte Stellen eigenständig neu. Die Grundstruktur darf sich ändern, wenn die Lesbarkeit gewinnt."
+ },
+ {
+ "id": "journal-generate-transformation-substantial",
+ "guideline_key": "substantial",
+ "label": "substanziell",
+ "summary": "Überarbeitet Sätze und Absätze eigenständig.",
+ "instruction": "Überarbeite den Text substanziell. Formuliere Sätze und Absätze eigenständig neu, wenn dadurch Lesbarkeit, Rhythmus oder Wirkung gewinnen. Bewahre nicht automatisch die Quellsyntax.",
+ "is_default": true
+ }
+ ]
+ },
+ "detail": {
+ "instruction_key": "detail_instructions",
+ "variants": [
+ {
+ "id": "journal-generate-detail-compact",
+ "guideline_key": "compact",
+ "label": "kompakt",
+ "summary": "Nur die tragenden belegten Ereignisse.",
+ "instruction": "Wähle nur die tragenden belegten Ereignisse. Lass nebensächliche Einzelheiten weg."
+ },
+ {
+ "id": "journal-generate-detail-selected",
+ "guideline_key": "selected",
+ "label": "ausgewählt",
+ "summary": "Wichtige Ereignisse und wenige markante Details.",
+ "instruction": "Behalte die wichtigen belegten Ereignisse und wenige markante Details."
+ },
+ {
+ "id": "journal-generate-detail-broad",
+ "guideline_key": "broad",
+ "label": "weitgehend",
+ "summary": "Die meisten belegten Ereignisse und einmaligen Details.",
+ "instruction": "Behalte die meisten belegten Ereignisse und einmaligen Details. Kürze nur Offensichtliches."
+ },
+ {
+ "id": "journal-generate-detail-complete",
+ "guideline_key": "complete",
+ "label": "vollständig",
+ "summary": "Alle belegten Ereignisse; nur echte Wiederholungen entfernen.",
+ "instruction": "Erhalte sämtliche belegten Ereignisse und einmaligen Details. Entferne nur echte Wiederholungen.",
+ "is_default": true
+ }
+ ]
+ },
+ "voice": {
+ "instruction_key": "voice_instructions",
+ "variants": [
+ {
+ "id": "journal-generate-voice-neutral",
+ "guideline_key": "neutral",
+ "label": "neutral",
+ "summary": "Neutraler Journalstil, Writing Profile nur als leise Tendenz.",
+ "instruction": "Schreibe in einem neutralen Journalstil. Das Writing Profile höchstens als leise Tendenz."
+ },
+ {
+ "id": "journal-generate-voice-light",
+ "guideline_key": "light",
+ "label": "dezent",
+ "summary": "Rhythmus und Wortwahl des Writing Profiles zurückhaltend.",
+ "instruction": "Nimm Rhythmus und Wortwahl des Writing Profiles zurückhaltend auf."
+ },
+ {
+ "id": "journal-generate-voice-noticeable",
+ "guideline_key": "noticeable",
+ "label": "spürbar",
+ "summary": "Writing Profile spürbar, ohne in den Vordergrund zu treten.",
+ "instruction": "Wende das Writing Profile spürbar an, ohne es in den Vordergrund zu stellen."
+ },
+ {
+ "id": "journal-generate-voice-clear",
+ "guideline_key": "clear",
+ "label": "deutlich",
+ "summary": "Rhythmus, Wortwahl und Reflexionsdichte des Writing Profiles deutlich.",
+ "instruction": "Wende Rhythmus, Wortwahl und Reflexionsdichte des Writing Profiles deutlich an, ohne den Text künstlich zu literarisieren.",
+ "is_default": true
+ }
+ ]
+ },
+ "narrative": {
+ "instruction_key": "narrative_instructions",
+ "variants": [
+ {
+ "id": "journal-generate-narrative-chronicle",
+ "guideline_key": "chronicle",
+ "label": "Chronik",
+ "summary": "Sachlich in der Reihenfolge der Quellen.",
+ "instruction": "Erzähle sachlich in der Reihenfolge der Quellen. Gewichte nichts extra."
+ },
+ {
+ "id": "journal-generate-narrative-structured",
+ "guideline_key": "structured",
+ "label": "gegliedert",
+ "summary": "Klar gegliedert, ohne besondere Momente extra herauszustellen.",
+ "instruction": "Gliedere den Tag klar. Besondere Momente nicht extra herausstellen."
+ },
+ {
+ "id": "journal-generate-narrative-weighted",
+ "guideline_key": "weighted",
+ "label": "gewichtet",
+ "summary": "Belegte Kontraste und besondere Momente erkennbar.",
+ "instruction": "Arbeite belegte Kontraste und besondere Momente erkennbar heraus. Erzeuge daraus keine neue Dramatik, Bewertung oder Kausalität.",
+ "is_default": true
+ },
+ {
+ "id": "journal-generate-narrative-emphasized",
+ "guideline_key": "emphasized",
+ "label": "betont",
+ "summary": "Belegte Kontraste und Höhepunkte im Vordergrund.",
+ "instruction": "Stelle belegte Kontraste und Höhepunkte deutlich in den Vordergrund. Erzeuge daraus keine neue Dramatik, Bewertung oder Kausalität."
+ }
+ ]
+ }
+ }
+}
diff --git a/backend/config/prompts.seed.json b/backend/config/prompts.seed.json
index f62bb0f..00d071e 100644
--- a/backend/config/prompts.seed.json
+++ b/backend/config/prompts.seed.json
@@ -13,12 +13,12 @@
"id": "mvp-journal-generate",
"slug": "mvp.journal_generate",
"name": "MVP Journalentwurf",
- "description": "Stufe 2: persönliche Narration aus dem lokalen Verified Artifact. Explizit ausgelöst. Nicht der Dialogzug.",
+ "description": "Stufe 2: persönliche Narration mit journalspezifischer Generation Policy. Explizit ausgelöst. Nicht der Dialogzug.",
"category": "mvp",
"prompt_type": "base",
"required_feature": "ai_calls",
- "template": "Schreibe eine inhaltstreue, redaktionell verbesserte Tagebuchfassung in der Ich-Form von [[SELF]].\n\nPriorität, höher schlägt niedriger:\n1. Keine neuen Informationen erfinden.\n2. Tatsachen, Bedeutung, Unsicherheit, Verneinung sowie Plan versus Vollzug bewahren.\n3. Rechtschreibung, Grammatik und Zeichensetzung korrigieren.\n4. Lesbarkeit, Satzbau, Wiederholungen, Absätze und Übergänge verbessern.\n5. WRITING_PROFILE und STYLE_EXAMPLES anwenden.\n6. Gute Originalformulierungen erhalten; schwache Formulierungen verbessern.\n7. Nur Titel und fertigen Journaltext ausgeben.\n\nFaktentreue ist nicht Wortlauttreue. Paraphrasieren und neu strukturieren ist erlaubt. Rechtschreibfehler sind keine geschützten Fakten. Unsicherheit bleibt Unsicherheit, muss aber nicht wortgleich bleiben. Sprachlich nötige Übergänge sind erlaubt, solange sie keine neue Ursache, Bewertung oder Handlung behaupten. Direkte Rede und bewusst stilprägende Formulierungen dürfen bleiben. Der Text muss nicht künstlich vom Ausgangstext abweichen. Ein hoher Wortlautanteil ist erlaubt, wenn der Ausgangstext bereits gut ist. Ein nahezu unveränderter Text mit übernommenen Fehlern und schwachen Übergängen erfüllt den Auftrag nicht.\n\nEDITORIAL_MODE: {{editorial_mode}}\n{{editorial_instructions}}\n\nKurze synthetische Arbeitsbeispiele, keine inhaltliche Schablone für den heutigen Tag:\nprose_edit — Rohtext: «ich gieng zum laden und es war kald.» wird zu «Ich ging zum Laden, und es war kalt.»\nnotes_to_journal — Rohtext: «- laden; - brot; - später park» wird zu «Im Laden holte ich Brot. Später war ich im Park.»\n\nWRITING_PROFILE (nur Schreibweise, keine zusätzlichen Tatsachen):\n{{writing_profile}}\n\nSTYLE_EXAMPLES (nur Ton, Rhythmus, sprachliche Entscheidungen; Inhalte nicht übernehmen):\n{{style_examples}}\n\nCURRENT_DAY_SOURCES (einzige Tatsachen des heutigen Eintrags, keine Ausgabevorlage):\n{{reconstruction}}\n\nEXISTING_TEXT, nur wenn ausdrücklich gewünscht:\n{{existing_text}}\n\nNamen und Orte nur als die im Kontext bereits vorhandenen Platzhalter schreiben, zeichengetreu und unverändert. Keine Klarnamen. Keine neuen Platzhalter. Keine Auslassungspunkte in doppelten Klammern. Ausgabe: erste Zeile kurze Überschrift, danach zusammenhängende Absätze. Keine Meta-Kommentare.\n",
- "seed_revision": "2026-08-27-journal-placeholders-v1"
+ "template": "Du redigierst einen persönlichen Tagebucheintrag in der Ich-Form.\n\nAUFGABE\nFormuliere aus CURRENT_DAY_SOURCES einen lesenswerten, eigenständigen Journaltext. Die Quellen bestimmen, was geschehen ist. WRITING_PROFILE bestimmt, wie es erzählt wird. Quellsyntax, Rechtschreibfehler und Dialogstruktur sind keine Ausgabevorlage.\n\nBEARBEITUNG\n{{transformation_instructions}}\n{{detail_instructions}}\n{{voice_instructions}}\n{{narrative_instructions}}\n\nINHALTSTREUE\nVerändere keine Ereignisse, Beteiligten, Orte, Zeiten oder zeitlichen Abläufe. Erhalte Verneinungen, Unsicherheiten, Korrekturen, ausdrücklich genannte Gefühle und Bewertungen sowie den Unterschied zwischen Plan und Vollzug. Ergänze keine neuen Tatsachen, Ursachen, Motive, Gefühle oder Hintergrundinformationen. Übergänge dürfen verbinden, aber nichts erklären, was die Quellen nicht erklären. Bei unklaren Satzfragmenten nicht raten, sondern neutral umformulieren oder nur den unverständlichen Teil weglassen.\n\nSTIL\nKorrigiere Rechtschreibung, Grammatik und Zeichensetzung. Verbessere Satzbau, Rhythmus, Übergänge und Absätze. Fasse echte Wiederholungen zusammen. Formuliere schwache oder fehlerhafte Stellen eigenständig neu. Gute persönliche Formulierungen dürfen erhalten bleiben. STYLE_EXAMPLES dienen ausschließlich als Stilreferenz; ihre Inhalte gehören nicht zum heutigen Tag.\nDie Quellen können aus Fließtext, Stichpunkten und Satzfragmenten bestehen. Behandle jede Passage entsprechend ihrer Form: Redigiere vorhandenen Fließtext, verwandle Stichpunkte und Fragmente in vollständige Prosa und verbinde beides zu einem einheitlichen Eintrag. Zwinge nicht den gesamten Tag in einen einzigen Quellenmodus.\n\nDATENSCHUTZ\nÜbernimm vorhandene Platzhalter wie [[PERSON:01]] unverändert. Erzeuge keine neuen Platzhalter und schreibe keine Klarnamen an ihre Stelle.\n\nKONTEXT\n\nWRITING_PROFILE\n{{writing_profile}}\n\nSTYLE_EXAMPLES\n{{style_examples}}\n\nCURRENT_DAY_SOURCES\n{{reconstruction}}\n\nEXISTING_TEXT\n{{existing_text}}\n\nAUSGABE\nErste Zeile: eine kurze passende Überschrift ohne neue Tatsachen. Danach zusammenhängende, natürlich gegliederte Absätze. Keine Aufzählung und keine Meta-Erklärung.\n",
+ "seed_revision": "2026-08-27-journal-mixed-sources-v1"
},
{
"id": "mvp-journal-reconstruct",
diff --git a/backend/context_builder.py b/backend/context_builder.py
index 393f3a5..efc0494 100644
--- a/backend/context_builder.py
+++ b/backend/context_builder.py
@@ -76,8 +76,10 @@ def build_internal_context(
reconstruction: str = "",
day_spec: dict | None = None,
style_examples: str = "",
- editorial_mode: str = "",
- editorial_instructions: str = "",
+ transformation_instructions: str = "",
+ detail_instructions: str = "",
+ voice_instructions: str = "",
+ narrative_instructions: str = "",
) -> dict:
purpose = purpose if purpose in PURPOSES else "dialogue_turn"
if conversation_id and not space_id:
@@ -154,8 +156,10 @@ def build_internal_context(
brief = compile_task_brief(profile_id, "journal_generate")
items.append({"type": "writing_profile", "compiled_brief": brief})
items.append({"type": "style_examples", "body": style_examples or ""})
- items.append({"type": "editorial_mode", "text": editorial_mode or ""})
- items.append({"type": "editorial_instructions", "text": editorial_instructions or ""})
+ items.append({"type": "transformation_instructions", "text": transformation_instructions or ""})
+ items.append({"type": "detail_instructions", "text": detail_instructions or ""})
+ items.append({"type": "voice_instructions", "text": voice_instructions or ""})
+ items.append({"type": "narrative_instructions", "text": narrative_instructions or ""})
if reconstruction:
items.append({"type": "reconstruction", "body": reconstruction})
if include_existing and existing_text:
@@ -186,8 +190,10 @@ def assemble_text(context: dict) -> dict[str, str]:
existing_text = ""
reconstruction = ""
style_examples = ""
- editorial_mode = ""
- editorial_instructions = ""
+ transformation_instructions = ""
+ detail_instructions = ""
+ voice_instructions = ""
+ narrative_instructions = ""
user_bodies: list[str] = []
purpose = context.get("purpose") or ""
for item in context.get("items") or []:
@@ -229,10 +235,14 @@ def assemble_text(context: dict) -> dict[str, str]:
reconstruction = item.get("body") or ""
elif kind == "style_examples":
style_examples = item.get("body") or ""
- elif kind == "editorial_mode":
- editorial_mode = item.get("text") or ""
- elif kind == "editorial_instructions":
- editorial_instructions = item.get("text") or ""
+ elif kind == "transformation_instructions":
+ transformation_instructions = item.get("text") or ""
+ elif kind == "detail_instructions":
+ detail_instructions = item.get("text") or ""
+ elif kind == "voice_instructions":
+ voice_instructions = item.get("text") or ""
+ elif kind == "narrative_instructions":
+ narrative_instructions = item.get("text") or ""
elif kind == "opening":
pass
opening_hint = ""
@@ -254,8 +264,10 @@ def assemble_text(context: dict) -> dict[str, str]:
"dialogue_context": "\n".join(dialogue_parts).strip(),
"writing_profile": writing_profile,
"style_examples": style_examples,
- "editorial_mode": editorial_mode,
- "editorial_instructions": editorial_instructions,
+ "transformation_instructions": transformation_instructions,
+ "detail_instructions": detail_instructions,
+ "voice_instructions": voice_instructions,
+ "narrative_instructions": narrative_instructions,
"interaction_hint": interaction_hint,
"existing_text": existing_text,
"reconstruction": reconstruction,
diff --git a/backend/db.py b/backend/db.py
index b4aa256..e0adb83 100644
--- a/backend/db.py
+++ b/backend/db.py
@@ -63,6 +63,9 @@ _WRITING_VERSION_COLUMNS = {
_JOURNAL_DAY_COLUMNS = {
"scratch_json": "TEXT NOT NULL DEFAULT '[]'",
}
+_JOURNAL_DRAFT_COLUMNS = {
+ "generation_snapshot": "TEXT NOT NULL DEFAULT '{}'",
+}
_IDENTITY_MAPPING_COLUMNS = {
"canonical_label": "TEXT NOT NULL DEFAULT ''",
"entity_type": "TEXT NOT NULL DEFAULT 'PERSON'",
@@ -449,6 +452,7 @@ def init_db() -> None:
_ensure_columns(conn, "profiles", _PROFILE_COLUMNS)
_ensure_columns(conn, "conversations", _CONVERSATION_COLUMNS)
_ensure_columns(conn, "journal_days", _JOURNAL_DAY_COLUMNS)
+ _ensure_columns(conn, "journal_drafts", _JOURNAL_DRAFT_COLUMNS)
_ensure_columns(conn, "writing_profiles", _WRITING_PROFILE_COLUMNS)
_migrate_writing_profile_dialogue_style(conn)
_ensure_columns(conn, "writing_profile_sources", _WRITING_SOURCE_COLUMNS)
@@ -474,6 +478,13 @@ def init_db() -> None:
_mark(conn, "012_profile_shell")
_mark(conn, "013_journal_generate_narration")
_mark(conn, "014_identity_registry")
+ from journal_generation_policy import backfill_missing_settings, seed_generation_instructions
+
+ seed_generation_instructions(conn)
+ backfill_missing_settings(conn)
+ _mark(conn, "015_journal_generation_settings")
+ _mark(conn, "016_generation_instruction_fragments")
+ _mark(conn, "017_generation_guidelines")
from writing_profile_store import bootstrap_from_existing
bootstrap_from_existing()
diff --git a/backend/engine.py b/backend/engine.py
index 4d78860..2447807 100644
--- a/backend/engine.py
+++ b/backend/engine.py
@@ -102,6 +102,7 @@ def execute_prompt(
"source_text": source_text,
"privacy_tokens": preview["privacy_tokens"],
"prompt_slug": prompt.get("slug"),
+ "prompt_revision": prompt.get("seed_revision") or "",
"max_tokens": max_tokens,
"disable_context_compression": disable_context_compression,
"budget": budget,
diff --git a/backend/entity_detect.py b/backend/entity_detect.py
index 7761555..5a4272d 100644
--- a/backend/entity_detect.py
+++ b/backend/entity_detect.py
@@ -262,10 +262,22 @@ def _contract_fake_spans(text: str) -> list[dict]:
found.append(item)
start = item["end"]
- if re.search(r"Sushi kam", source):
- add("Sushi", "PERSON")
if re.search(r"(?i)Frau\s+Sushi", source):
add("Sushi", "PERSON")
+ else:
+ for match in re.finditer(r"Sushi", source):
+ if source[match.end() : match.end() + 4] == " kam":
+ key = (match.start(), match.end(), "PERSON")
+ if key not in seen:
+ seen.add(key)
+ found.append(
+ {
+ "start": match.start(),
+ "end": match.end(),
+ "text": "Sushi",
+ "entity_type": "PERSON",
+ }
+ )
if re.search(r"(?i)Projekt\s+Aurora", source):
add("Aurora", "PROJECT")
if re.search(r"(?i)(Organisation|Firma|bei)\s+Nordwerk", source):
@@ -499,6 +511,8 @@ def _assign_request_tokens(
confirmed_hits += 1
entity_type = found["entity_type"]
demask = canonical
+ aliases = list(found.get("aliases") or [])
+ labels = confirmed_match_labels(found)
else:
key = f"{span.entity_type}:{label.casefold()}"
if key not in by_label:
@@ -511,6 +525,8 @@ def _assign_request_tokens(
entity_type = span.entity_type
source = "request_local"
demask = label
+ aliases = []
+ labels = [label]
mappings.append(
{
"token": token,
@@ -521,6 +537,8 @@ def _assign_request_tokens(
"source": source,
"start": span.start,
"end": span.end,
+ "aliases": aliases,
+ "labels": labels,
}
)
return mappings, request_local, confirmed_hits
@@ -529,17 +547,17 @@ def _assign_request_tokens(
def _merge_confirmed_safety_net(text: str, mappings: list[dict], profile_id: str | None) -> tuple[list[dict], int]:
if not profile_id:
return mappings, 0
- already = {(item.get("local_label") or "").casefold() for item in mappings}
+ already_labels = {(item.get("local_label") or "").casefold() for item in mappings if item.get("start") is None}
extra: list[dict] = []
hits = 0
for row in masking_rows_from_confirmed(profile_id):
label = row.get("local_label") or ""
- if not label or label.casefold() in already:
+ if not label or label.casefold() in already_labels:
continue
if not re.search(rf"(? Detectio
"token": item.get("token"),
"entity_type": item.get("entity_type"),
"demask_label": item.get("demask_label") or item.get("local_label"),
+ "canonical_label": item.get("canonical_label") or item.get("demask_label") or item.get("local_label"),
+ "aliases": list(item.get("aliases") or []),
+ "labels": list(item.get("labels") or []),
"source": item.get("source"),
}
for item in mappings
diff --git a/backend/entity_detect_eval.py b/backend/entity_detect_eval.py
index 45b0e70..80d2b55 100644
--- a/backend/entity_detect_eval.py
+++ b/backend/entity_detect_eval.py
@@ -5,7 +5,8 @@ Usage from backend/:
python entity_detect_eval.py --live
Synthetic sentences only. No personal data. Live quality stays unconfirmed
-until an explicit --live run succeeds.
+until an explicit --live run succeeds against gold spans.
+Fake mode proves the scoring contract, not live semantic quality.
"""
from __future__ import annotations
@@ -20,45 +21,158 @@ ROOT = Path(__file__).resolve().parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
+
+def _span(text: str, needle: str, entity_type: str, *, occurrence: int = 0) -> dict:
+ start = -1
+ found = -1
+ while True:
+ start = text.find(needle, start + 1)
+ if start < 0:
+ raise ValueError(f"needle {needle!r} occurrence {occurrence} missing in {text!r}")
+ found += 1
+ if found == occurrence:
+ return {
+ "start": start,
+ "end": start + len(needle),
+ "text": needle,
+ "entity_type": entity_type,
+ }
+
+
CASES = (
{
- "id": "common_noun",
- "text": "Ich ging auf den Balkon und setzte mich.",
- "expect_empty_types": True,
- "note": "Allgemeines Substantiv, kein Eigenname.",
+ "id": "person_vs_food",
+ "text": "Ich aß Sushi. Sushi kam später.",
+ "expected": [_span("Ich aß Sushi. Sushi kam später.", "Sushi", "PERSON", occurrence=1)],
+ "note": "Person versus Lebensmittel: nur die identifizierende Nennung.",
},
{
- "id": "food_vs_person",
- "text": "Ich aß Sushi. Sushi kam ins Wohnzimmer.",
- "note": "Dasselbe Wort als Gericht und als mögliche Person.",
+ "id": "project_vs_activity",
+ "text": "Ich arbeitete am privaten Projekt Aurora. Danach arbeitete ich.",
+ "expected": [_span("Ich arbeitete am privaten Projekt Aurora. Danach arbeitete ich.", "Aurora", "PROJECT")],
+ "note": "Privates Projekt versus allgemeine Tätigkeit.",
},
{
- "id": "person",
- "text": "Ich traf Anna am Nachmittag.",
- "expect_types": {"PERSON"},
- "note": "Klarer Personenname.",
+ "id": "place_vs_room",
+ "text": "Ich war in Hamburg. Später saß ich im Wohnzimmer.",
+ "expected": [_span("Ich war in Hamburg. Später saß ich im Wohnzimmer.", "Hamburg", "PLACE")],
+ "note": "Genauer Eigenort versus allgemeiner Raum.",
},
{
- "id": "project",
- "text": "Ich arbeitete am privaten Projekt Aurora.",
- "expect_types": {"PROJECT"},
- "note": "Privates Projekt, kein Allerweltsgegenstand.",
+ "id": "org_vs_noun",
+ "text": "Ich sprach mit der Firma Nordwerk. Das Notizbuch blieb liegen.",
+ "expected": [_span("Ich sprach mit der Firma Nordwerk. Das Notizbuch blieb liegen.", "Nordwerk", "ORG")],
+ "note": "Organisation versus allgemeines Substantiv.",
},
{
- "id": "place_org",
+ "id": "person_vs_group",
+ "text": "Anna kam vorbei. Die Nachbarn spielten draußen.",
+ "expected": [_span("Anna kam vorbei. Die Nachbarn spielten draußen.", "Anna", "PERSON")],
+ "note": "Personenname versus generische Personengruppe.",
+ },
+ {
+ "id": "animal_vs_org",
+ "text": "Die Seehunde schwammen nah am Ufer.",
+ "expected": [],
+ "note": "Tierbezeichnung versus Organisation oder Person.",
+ },
+ {
+ "id": "typo_common",
+ "text": "Ich gieng zum Laden.",
+ "expected": [],
+ "note": "Tippfehler eines Allgemeinbegriffs ist keine Identität.",
+ },
+ {
+ "id": "same_word_two_roles",
+ "text": "Ich aß Sushi. Sushi kam ins Zimmer.",
+ "expected": [_span("Ich aß Sushi. Sushi kam ins Zimmer.", "Sushi", "PERSON", occurrence=1)],
+ "note": "Identischer Wortlaut in zwei semantischen Rollen.",
+ },
+ {
+ "id": "multipart_place",
"text": "Ich war in Hamburg und sprach mit der Organisation Nordwerk.",
- "expect_types": {"PLACE", "ORG"},
- "note": "Ort und Organisation.",
+ "expected": [
+ _span("Ich war in Hamburg und sprach mit der Organisation Nordwerk.", "Hamburg", "PLACE"),
+ _span("Ich war in Hamburg und sprach mit der Organisation Nordwerk.", "Nordwerk", "ORG"),
+ ],
+ "note": "Mehrteiliger Ort plus Organisation, keine Ganzsatz-Spans.",
+ },
+ {
+ "id": "no_sentence_span",
+ "text": "Ich traf Anna am Nachmittag.",
+ "expected": [_span("Ich traf Anna am Nachmittag.", "Anna", "PERSON")],
+ "note": "Nur der Name, kein Ganzsatz- oder Satzfragment-Span.",
},
)
-def _summarize(entities: list[dict]) -> dict:
+def _key(item: dict) -> tuple[int, int, str, str]:
+ return (
+ int(item.get("start") or -1),
+ int(item.get("end") or -1),
+ str(item.get("text") or ""),
+ str(item.get("entity_type") or "").upper(),
+ )
+
+
+def _offset_key(item: dict) -> tuple[int, int, str]:
+ return (int(item.get("start") or -1), int(item.get("end") or -1), str(item.get("text") or ""))
+
+
+def score_spans(predicted: list[dict], expected: list[dict]) -> dict:
+ pred = [_key(item) for item in predicted]
+ gold = [_key(item) for item in expected]
+ pred_off = [_offset_key(item) for item in predicted]
+ gold_off = [_offset_key(item) for item in expected]
+ found = [item for item in gold if item in pred]
+ unexpected = [item for item in pred if item not in gold]
+ missing = [item for item in gold if item not in pred]
+ wrong_type = []
+ wrong_offset = []
+ for item in predicted:
+ matches_text = [
+ gold_item
+ for gold_item in expected
+ if (gold_item.get("text") or "") == (item.get("text") or "")
+ ]
+ if not matches_text:
+ continue
+ if _key(item) in gold:
+ continue
+ same_offsets = any(_offset_key(item) == _offset_key(gold_item) for gold_item in matches_text)
+ if same_offsets:
+ wrong_type.append(_key(item))
+ else:
+ wrong_offset.append(_key(item))
+ tp = len(found)
+ fp = len(unexpected)
+ fn = len(missing)
+ precision = tp / (tp + fp) if (tp + fp) else 1.0
+ recall = tp / (tp + fn) if (tp + fn) else 1.0
+ return {
+ "expected_found": tp,
+ "expected_count": len(gold),
+ "unexpected": fp,
+ "missing": fn,
+ "wrong_type": len(wrong_type),
+ "wrong_offset": len(wrong_offset),
+ "precision": round(precision, 4),
+ "recall": round(recall, 4),
+ "sentence_or_fragment_span": any(
+ (item.get("end") or 0) - (item.get("start") or 0) > max(len(item.get("text") or ""), 0)
+ and " " in (item.get("text") or "")
+ for item in predicted
+ ),
+ }
+
+
+def _summarize(entities: list[dict], expected: list[dict]) -> dict:
types = sorted({(item.get("entity_type") or "").upper() for item in entities})
return {
"count": len(entities),
"types": types,
"has_labels": False,
+ "metrics": score_spans(entities, expected),
}
@@ -68,8 +182,20 @@ def run_fake() -> dict:
rows = []
for case in CASES:
entities = _contract_fake_spans(case["text"])
- rows.append({"id": case["id"], "note": case["note"], "entities": _summarize(entities), "mode": "fake"})
- return {"mode": "fake", "live_quality": "unconfirmed", "cases": rows}
+ rows.append(
+ {
+ "id": case["id"],
+ "note": case["note"],
+ "entities": _summarize(entities, case["expected"]),
+ "mode": "fake",
+ }
+ )
+ return {
+ "mode": "fake",
+ "live_quality": "unconfirmed",
+ "detect_model_unconfirmed": "openai/gpt-4.1-nano",
+ "cases": rows,
+ }
def run_live() -> dict:
@@ -82,21 +208,35 @@ def run_live() -> dict:
started = time.perf_counter()
outcome = detect_personal_egress(None, case["text"])
elapsed = int((time.perf_counter() - started) * 1000)
- types = sorted({(item.get("entity_type") or "") for item in outcome.mappings})
+ predicted = [
+ {
+ "start": item.get("start"),
+ "end": item.get("end"),
+ "text": item.get("local_label") or item.get("text"),
+ "entity_type": item.get("entity_type"),
+ }
+ for item in outcome.mappings
+ ]
rows.append(
{
"id": case["id"],
"note": case["note"],
- "types": types,
+ "metrics": score_spans(predicted, case["expected"]),
"request_local_hits": outcome.stats.request_local_hits,
"detect_calls": outcome.stats.detect_calls,
"detect_ms": elapsed,
"coverage": outcome.stats.full_detection_coverage,
"detect_tokens": outcome.stats.total_tokens,
"detect_cost": outcome.stats.cost,
+ "detect_model": outcome.stats.detect_model,
}
)
- return {"mode": "live", "live_quality": "ran", "cases": rows}
+ return {
+ "mode": "live",
+ "live_quality": "unconfirmed",
+ "detect_model_unconfirmed": "openai/gpt-4.1-nano",
+ "cases": rows,
+ }
def main() -> None:
@@ -105,8 +245,12 @@ def main() -> None:
args = parser.parse_args()
payload = run_live() if args.live else run_fake()
print(json.dumps(payload, ensure_ascii=False, indent=2))
+ print(
+ "Live-Qualität: unbestätigt. Das konfigurierte openai/gpt-4.1-nano "
+ "gilt durch reale False-Positive-Vorschläge nicht als zuverlässig bestätigt."
+ )
if payload["mode"] != "live":
- print("Live-Qualität: noch nicht bestätigt. Explizit: python entity_detect_eval.py --live")
+ print("Explizit live: python entity_detect_eval.py --live")
if __name__ == "__main__":
diff --git a/backend/identity_store.py b/backend/identity_store.py
index f6e7ab8..4a4f309 100644
--- a/backend/identity_store.py
+++ b/backend/identity_store.py
@@ -182,6 +182,28 @@ def confirmed_match_labels(item: dict) -> list[str]:
return labels
+def mapping_spellings(item: dict) -> list[str]:
+ """All known writings of one mapping: canonical, aliases, observed label, demask form."""
+ seen: set[str] = set()
+ result: list[str] = []
+
+ def add(raw) -> None:
+ label = (raw or "").strip()
+ if not label or label.casefold() in seen or not is_maskable_label(label):
+ return
+ seen.add(label.casefold())
+ result.append(label)
+
+ add(item.get("local_label"))
+ add(item.get("canonical_label"))
+ add(item.get("demask_label"))
+ for alias in item.get("aliases") or []:
+ add(alias)
+ for label in item.get("labels") or []:
+ add(label)
+ return result
+
+
def masking_rows_from_confirmed(profile_id: str) -> list[dict]:
"""One masking row per confirmed canonical label or alias. Demask uses canonical."""
rows: list[dict] = []
@@ -202,6 +224,8 @@ def masking_rows_from_confirmed(profile_id: str) -> list[dict]:
"demask_label": canonical,
"entity_type": entity_type,
"source": "confirmed_registry",
+ "aliases": [item for item in confirmed_match_labels(item) if item.casefold() != label.casefold()],
+ "labels": confirmed_match_labels(item),
}
)
return rows
diff --git a/backend/journal_editorial.py b/backend/journal_editorial.py
index a94c87b..fdaadb9 100644
--- a/backend/journal_editorial.py
+++ b/backend/journal_editorial.py
@@ -1,7 +1,8 @@
"""Journal-adapter editorial policy. Not a general provenance or privacy rule.
-Fact fidelity is not wording fidelity. Editorial mode is chosen locally, without
-a second model call. Historical texts are style references, never today's facts.
+Fact fidelity is not wording fidelity. Mixed prose, notes and fragments in the
+same day are the normal case and are handled in one generate call. Historical
+texts are style references, never today's facts.
"""
from __future__ import annotations
@@ -18,29 +19,11 @@ from writing_profile_store import (
list_style_sources,
)
-PROSE_EDIT = "prose_edit"
-NOTES_TO_JOURNAL = "notes_to_journal"
-EDITORIAL_MODES = (PROSE_EDIT, NOTES_TO_JOURNAL)
-
STYLE_EXAMPLE_MAX = 2
STYLE_EXAMPLE_CHARS = 900
MIN_EXAMPLE_CHARS = 40
-INSTRUCTIONS = {
- PROSE_EDIT: (
- "Modus prose_edit: Der Rohtext ist bereits erzählerisch. "
- "Gute Formulierungen bewahren. Rechtschreibung, Grammatik und Zeichensetzung "
- "korrigieren. Holprige Stellen glätten, Wiederholungen reduzieren, Absätze und "
- "Übergänge verbessern. Die persönliche Schreibstimme anwenden. "
- "Keine unnötige vollständige Neufassung erzwingen."
- ),
- NOTES_TO_JOURNAL: (
- "Modus notes_to_journal: Die Quellen sind Stichpunkte, Kurztexte oder Fragmente. "
- "Daraus zusammenhängende Journalprosa bilden. Nur sprachlich nötige Verbindungen "
- "herstellen. Keine neuen Tatsachen, Ursachen oder Bewertungen ergänzen. "
- "Die Stichpunkte nicht inklusive ihrer Fehler hintereinanderkopieren."
- ),
-}
+GENERATE_SEED_REVISION = "2026-08-27-journal-mixed-sources-v1"
EMPTY_STYLE_EXAMPLES = (
"Keine historischen Stilbeispiele. Es gilt nur WRITING_PROFILE "
@@ -48,26 +31,6 @@ EMPTY_STYLE_EXAMPLES = (
)
-def choose_editorial_mode(user_bodies: list[str]) -> str:
- """MVP mode choice. No classifier model.
-
- A source block counts as already narrative when it contains `.`, `!` or `?`.
- Mixed default: `prose_edit` when at least half of the non-empty blocks are
- narrative; otherwise `notes_to_journal`.
- """
- blocks = [(item or "").strip() for item in user_bodies if (item or "").strip()]
- if not blocks:
- return NOTES_TO_JOURNAL
- narrative = sum(1 for block in blocks if any(mark in block for mark in ".!?"))
- if narrative * 2 >= len(blocks):
- return PROSE_EDIT
- return NOTES_TO_JOURNAL
-
-
-def editorial_instructions(mode: str) -> str:
- return INSTRUCTIONS.get(mode) or INSTRUCTIONS[PROSE_EDIT]
-
-
def lexical_similarity(left: str, right: str) -> float:
"""Diagnostic only. Must not reject a draft or trigger a retry."""
a = re.sub(r"\s+", " ", (left or "").strip().lower())
@@ -77,6 +40,85 @@ def lexical_similarity(left: str, right: str) -> float:
return round(SequenceMatcher(None, a, b).ratio(), 3)
+_DANGLING_DETERMINER = re.compile(
+ r"(?i)\b(?:der|die|das|des|dem|den|ein|eine|einem|einen|einer)\s*$"
+)
+_DETERMINER_BEFORE_FINITE = re.compile(
+ r"(?i)\b(?:des|dem|den|der|die|das|ein|eine|einem|einen|einer)\s+"
+ r"(?:kann|können|konnte|muss|müssen|will|wollen|soll|sollen|"
+ r"ist|sind|war|waren|wird|werden|hat|haben|hatte|"
+ r"geht|gehen|ging|kam|kommen|kommt|gelangen|gelangte)\b"
+)
+
+
+def incomplete_syntax_markers(text: str) -> int:
+ """Diagnostic count of dangling determiners or unpunctuated long clauses.
+
+ Does not rewrite text and must not trigger a retry.
+ """
+ body = (text or "").strip()
+ if not body:
+ return 0
+ count = 0
+ clauses = [part.strip() for part in re.split(r"(?<=[.!?])\s+|\n+", body) if part.strip()]
+ for clause in clauses:
+ bare = clause.rstrip(".!?…\"»'")
+ if _DANGLING_DETERMINER.search(bare):
+ count += 1
+ if _DETERMINER_BEFORE_FINITE.search(clause):
+ count += 1
+ for para in re.split(r"\n\s*\n", body):
+ chunk = para.strip()
+ if len(chunk.split()) >= 8 and not re.search(r"[.!?]", chunk):
+ count += 1
+ return count
+
+
+def style_example_diagnostics(examples: list[dict]) -> dict:
+ return {
+ "count": len(examples or []),
+ "kinds": [item.get("kind") or "style" for item in (examples or [])],
+ "chars": sum(len(item.get("excerpt") or "") for item in (examples or [])),
+ }
+
+
+def writing_profile_trace(profile_id: str) -> dict:
+ """Presence metadata only. No profile text, no labels."""
+ from writing_profile_store import (
+ NEUTRAL_JOURNAL_STYLE,
+ compile_task_brief,
+ get_profile,
+ has_confirmed_profile,
+ )
+
+ confirmed = has_confirmed_profile(profile_id)
+ profile = get_profile(profile_id)
+ core = ((profile.get("core") or {}).get("value") or "").strip()
+ facet = next(
+ (
+ item
+ for item in profile.get("facets") or []
+ if item.get("facet_key") == "autobiographical_journal" and (item.get("value") or "").strip()
+ ),
+ None,
+ )
+ traits = [
+ item
+ for item in profile.get("traits") or []
+ if item.get("status") == "active" and (item.get("statement") or "").strip()
+ ]
+ brief = compile_task_brief(profile_id, "journal_generate")
+ return {
+ "confirmed": confirmed,
+ "present": bool(confirmed and brief and brief != NEUTRAL_JOURNAL_STYLE),
+ "neutral_fallback": brief == NEUTRAL_JOURNAL_STYLE,
+ "has_core": bool(confirmed and core),
+ "has_facet": bool(confirmed and facet),
+ "trait_count": len(traits) if confirmed else 0,
+ "brief_chars": len(brief or ""),
+ }
+
+
def narration_sources_text(artifact: dict) -> str:
"""Present attested day facts to the model. Not a wording template, not JSON."""
parts: list[str] = []
diff --git a/backend/journal_eval.py b/backend/journal_eval.py
index 69aff20..6a393b1 100644
--- a/backend/journal_eval.py
+++ b/backend/journal_eval.py
@@ -3,9 +3,10 @@
Usage from backend/:
python journal_eval.py # synthetic, fake provider, contract only
python journal_eval.py --live --profile-id
+ python journal_eval.py --profile-ab # two synthetic writing profiles, prompts only unless --live
Never writes private texts into the repository. Live quality stays unconfirmed
-until an explicit --live run succeeds.
+until an explicit --live run succeeds. The harness never declares a winner.
"""
from __future__ import annotations
@@ -28,8 +29,11 @@ VARIANT_PREVIOUS = "kansho_previous"
VARIANT_CURRENT = "kansho_current"
BASELINE_TEMPLATE = (
- "Überarbeite diesen Rohtext zu einem ansprechenden Tagebucheintrag in meinem Stil.\n\n"
- "{{reconstruction}}\n"
+ "Erstelle aus diesen Angaben einen ansprechenden persönlichen Tagebucheintrag. "
+ "Bewahre alle Tatsachen und Unsicherheiten, erfinde nichts, korrigiere Sprache "
+ "und schreibe im bereitgestellten persönlichen Stil.\n\n"
+ "Persönlicher Stil:\n{{writing_profile}}\n\n"
+ "Angaben:\n{{reconstruction}}\n"
)
PREVIOUS_TEMPLATE = (
@@ -41,13 +45,112 @@ PREVIOUS_TEMPLATE = (
"Verified Artifact:\n{{reconstruction}}\n"
)
-SYNTHETIC_PROSE = (
- "ich bin dan zum markt gegangen und da war es zimlich voll. "
- "vielleicht bleibe ich kürzer. ich wollte noch brot holen, hab es aber nicht gemacht. "
- "die rote tasche lag im auto."
+PROFILE_A = (
+ "Core: kurze Sätze, trockener Schnitt, wenig Adjektive, kaum Reflexion. "
+ "Wortwahl nüchtern, Rhythmus abgehackt."
)
-SYNTHETIC_NOTES = "- markt\n- kirschen kaufen\n- später hafen"
-SYNTHETIC_TYPOS = ("zimlich", "dan zum")
+PROFILE_B = (
+ "Core: längere, ruhig fließende Sätze, beobachtend, leise Reflexion am Satzende. "
+ "Wortwahl behutsam, Rhythmus getragen."
+)
+
+FIXTURES: list[dict[str, Any]] = [
+ {
+ "id": "prose_typos",
+ "class": "already_narrative_with_errors",
+ "source": (
+ "ich bin dan zum markt gegangen und da war es zimlich voll. "
+ "vielleicht bleibe ich kürzer. ich wollte noch brot holen, hab es aber nicht gemacht. "
+ "die rote tasche lag im auto."
+ ),
+ "typos": ("zimlich", "dan zum"),
+ "must_keep": ("vielleicht", "nicht gemacht", "rote tasche"),
+ "must_not_invent": ("traurig", "weil"),
+ },
+ {
+ "id": "notes_fragments",
+ "class": "bullet_points_and_fragments",
+ "source": "markt\nkirschen kaufen\nspäter hafen",
+ "typos": (),
+ "must_keep": ("markt", "kirschen", "hafen"),
+ "must_not_invent": ("glücklich", "weil"),
+ },
+ {
+ "id": "plan_vs_done",
+ "class": "plan_versus_completion",
+ "source": "Ich wollte um elf zum Hafen. Stattdessen blieb ich zu Hause. Den Brief habe ich nicht abgeschickt.",
+ "typos": (),
+ "must_keep": ("wollte", "blieb", "nicht abgeschickt"),
+ "must_not_invent": ("geschickt", "bin zum hafen"),
+ },
+ {
+ "id": "negation_uncertainty",
+ "class": "negation_and_uncertainty",
+ "source": "Vielleicht kommt der Techniker. Ich bin unsicher wegen des Tees. Den Kuchen habe ich nicht gebacken.",
+ "typos": (),
+ "must_keep": ("vielleicht", "unsicher", "nicht gebacken"),
+ "must_not_invent": ("sicher", "gebacken"),
+ },
+ {
+ "id": "correction",
+ "class": "correction_of_earlier_claim",
+ "source": "Zuerst dachte ich, der Markt sei um neun. Später korrigierte ich mich: er war schon um acht zu Ende.",
+ "typos": (),
+ "must_keep": ("zuerst", "korrigierte", "acht"),
+ "must_not_invent": ("neun zu ende",),
+ },
+ {
+ "id": "imprecise_time",
+ "class": "imprecise_time",
+ "source": "Gegen sechs bin ich aufgewacht. Irgendwann vorm Mittag war ich am Markt.",
+ "typos": (),
+ "must_keep": ("gegen sechs", "irgendwann"),
+ "must_not_invent": ("genau 6:00", "12:00"),
+ },
+ {
+ "id": "outstanding_event",
+ "class": "outstanding_event_among_everyday",
+ "source": (
+ "gegen 6 uhr aufgewacht\n"
+ "morgenroutine mit sprache, notizen und tee\n"
+ "unsichere teepraeferenz\n"
+ "techniker sollte um 11 uhr kommen\n"
+ "kueche aufgeraeumt\n"
+ "gegen 9 uhr gefruehstueckt\n"
+ "kinder erst gegen 10 oder 11 uhr aufgestanden\n"
+ "normaler strandtag\n"
+ "ploetzlich seehunde im wasser gesehen"
+ ),
+ "typos": ("praeferenz", "aufgeraeumt", "gefruehstueckt"),
+ "must_keep": ("normaler strandtag", "seehunde", "unsichere"),
+ "must_not_invent": ("gluecklich", "schicksal"),
+ "weight_tokens": ("seehunde", "normaler"),
+ },
+ {
+ "id": "recurring_people",
+ "class": "recurring_people_and_projects",
+ "source": (
+ "Am Vormittag sprach ich mit [[PERSON:01]] über Projekt [[PROJECT:01]]. "
+ "Später half [[PERSON:01]] beim Aufräumen. [[PROJECT:01]] blieb liegen."
+ ),
+ "typos": (),
+ "must_keep": ("[[PERSON:01]]", "[[PROJECT:01]]", "blieb liegen"),
+ "must_not_invent": ("[[PERSON:02]]",),
+ },
+ {
+ "id": "incomplete_clause",
+ "class": "incomplete_source_rebuildable",
+ "source": "ich gieng zum laden. danach sprach ich mit dem und kam zurück. es war kald.",
+ "typos": ("gieng", "kald"),
+ "must_keep": ("laden", "kam zurück"),
+ "must_not_invent": ("nachbarn", "freund"),
+ "incomplete_ok": False,
+ },
+]
+
+SYNTHETIC_PROSE = FIXTURES[0]["source"]
+SYNTHETIC_NOTES = FIXTURES[1]["source"]
+SYNTHETIC_TYPOS = FIXTURES[0]["typos"]
def _words(text: str) -> list[str]:
@@ -67,13 +170,15 @@ def score_output(source: str, output: str, *, profile: str = "", typos: tuple[st
sentences = _sentences(body)
paragraphs = [item for item in re.split(r"\n\s*\n", body) if item.strip()]
avg_len = round(sum(len(item.split()) for item in sentences) / max(1, len(sentences)), 2)
- transitions = len(re.findall(r"(?i)\b(?:danach|später|dann|zuerst|schließlich)\b", body))
+ transitions = len(re.findall(r"(?i)\b(?:danach|später|dann|zuerst|schließlich|bis|plötzlich)\b", body))
profile_words = set(_words(profile))
style_overlap = round(len(out_words & profile_words) / max(1, len(profile_words)), 3) if profile_words else 0.0
kept = round(len(src_words & out_words) / max(1, len(src_words)), 3)
extra = sorted(out_words - src_words - profile_words)
lost = sorted(src_words - out_words)
remaining_typos = [item for item in typos if item.lower() in body.lower()]
+ from journal_editorial import incomplete_syntax_markers
+
return {
"spelling_typos_remaining": remaining_typos,
"spelling_typos_fixed": [item for item in typos if item.lower() not in body.lower()],
@@ -86,6 +191,8 @@ def score_output(source: str, output: str, *, profile: str = "", typos: tuple[st
"new_info_tokens": extra[:24],
"lost_info_tokens": lost[:24],
"lexical_similarity": lexical_similarity(source, body),
+ "incomplete_syntax": incomplete_syntax_markers(body),
+ "unresolved_privacy_tokens": bool(re.search(r"\[\[\s*(?:…|\.{2,})\s*\]\]", body)),
}
@@ -100,6 +207,31 @@ def variant_templates() -> dict[str, str]:
}
+def fixture_context(source: str, *, writing_profile: str | None = None) -> dict[str, str]:
+ artifact = {
+ "kind": "verified_artifact",
+ "coverage": "all_selected_sources",
+ "sources": [{"source_id": "u1", "role": "user", "text": source}],
+ }
+ from journal_editorial import narration_sources_text
+ from journal_generation_policy import compile_selection, default_selection_ids
+ from writing_profile_store import NEUTRAL_JOURNAL_STYLE
+
+ compiled = compile_selection(default_selection_ids())
+ return {
+ "reconstruction": narration_sources_text(artifact) or source,
+ "writing_profile": writing_profile or NEUTRAL_JOURNAL_STYLE,
+ "style_examples": "Keine historischen Stilbeispiele.",
+ **compiled.instructions,
+ "existing_text": "",
+ "space_title": "Eval",
+ }
+
+
+def synthetic_context(source: str) -> dict[str, str]:
+ return fixture_context(source)
+
+
def run_variant(
name: str,
template: str,
@@ -109,13 +241,14 @@ def run_variant(
live: bool,
) -> dict[str, Any]:
from engine import execute_prompt
+ from model_catalog import resolve_generate_metadata
from prompt_budget import plan_journal_budget
from providers import generate_provider
- from model_catalog import resolve_generate_metadata
prompt = {
"id": f"eval-{name}",
"slug": "mvp.journal_generate" if name == VARIANT_CURRENT else f"eval.{name}",
+ "seed_revision": "eval",
"prompt_type": "base",
"required_feature": "ai_calls",
"template": template,
@@ -145,87 +278,136 @@ def run_variant(
"total_tokens": diag.get("total_tokens"),
"cost": diag.get("cost"),
"runtime_ms": elapsed_ms,
+ "generate_ms": diag.get("generate_ms"),
"model": (result.get("trace") or {}).get("model") or diag.get("actual_model"),
+ "prompt_revision": diag.get("prompt_revision"),
"fake_provider": not live,
+ "rendered_contains_profile": (context.get("writing_profile") or "")[:40]
+ in ((result.get("trace") or {}).get("intern") or ""),
}
-def synthetic_context(source: str) -> dict[str, str]:
- artifact = {
- "kind": "verified_artifact",
- "coverage": "all_selected_sources",
- "sources": [{"source_id": "u1", "role": "user", "text": source}],
- }
- from journal_editorial import (
- NOTES_TO_JOURNAL,
- PROSE_EDIT,
- choose_editorial_mode,
- editorial_instructions,
- narration_sources_text,
- )
- from writing_profile_store import NEUTRAL_JOURNAL_STYLE
-
- mode = choose_editorial_mode([source])
- return {
- "reconstruction": narration_sources_text(artifact) or source,
- "writing_profile": NEUTRAL_JOURNAL_STYLE,
- "style_examples": "Keine historischen Stilbeispiele.",
- "editorial_mode": mode,
- "editorial_instructions": editorial_instructions(mode),
- "existing_text": "",
- "space_title": "Eval",
- "expected_mode": PROSE_EDIT if mode == PROSE_EDIT else NOTES_TO_JOURNAL,
- }
-
-
-def compare_synthetic(*, live: bool = False, profile_id: str | None = None) -> dict[str, Any]:
+def _offline_row(name: str, template: str, context: dict[str, str]) -> dict[str, Any]:
from placeholders import resolve_template
- import placeholder_mvp # noqa: F401
from privacy_gateway import _fake_complete
+ rendered = resolve_template(template, context)
+ started = time.perf_counter()
+ content = _fake_complete("journal_generate", rendered)
+ return {
+ "variant": name,
+ "live": False,
+ "content": content,
+ "prompt_tokens": None,
+ "completion_tokens": None,
+ "total_tokens": None,
+ "cost": None,
+ "runtime_ms": int((time.perf_counter() - started) * 1000),
+ "model": "fake",
+ "fake_provider": True,
+ "rendered": rendered,
+ "rendered_contains_profile": (context.get("writing_profile") or "")[:24] in rendered,
+ }
+
+
+def _human_blind_pair(left: dict, right: dict) -> dict[str, Any]:
+ """Side-by-side without naming a winner. Mapping stays in the machine report."""
+ return {
+ "prompt_1": {"label": "Prompt 1", "text": left.get("content") or ""},
+ "prompt_2": {"label": "Prompt 2", "text": right.get("content") or ""},
+ "hidden_mapping": {
+ "prompt_1": left.get("variant"),
+ "prompt_2": right.get("variant"),
+ },
+ "instruction": (
+ "Blind bewerten: Faktentreue, Unsicherheit, Sprache, Lesefluss, "
+ "Gewichtung, Profiltreue, Eigenständigkeit. Keine automatische Siegeraussage."
+ ),
+ }
+
+
+def compare_synthetic(
+ *,
+ live: bool = False,
+ profile_id: str | None = None,
+ profile_ab: bool = False,
+) -> dict[str, Any]:
+ import placeholder_mvp # noqa: F401
+
templates = variant_templates()
- source = SYNTHETIC_PROSE
- context = synthetic_context(source)
- rows = []
- for name, template in templates.items():
+ fixtures_out = []
+ for fixture in FIXTURES:
+ context = fixture_context(fixture["source"])
+ rows = []
+ for name, template in templates.items():
+ if live:
+ if not profile_id:
+ raise SystemExit("--live requires --profile-id")
+ row = run_variant(name, template, context, profile_id=profile_id, live=True)
+ else:
+ row = _offline_row(name, template, context)
+ row["scores"] = score_output(
+ fixture["source"],
+ row["content"],
+ profile=context.get("writing_profile") or "",
+ typos=tuple(fixture.get("typos") or ()),
+ )
+ rows.append(row)
+ current = next((item for item in rows if item["variant"] == VARIANT_CURRENT), rows[-1])
+ baseline = next((item for item in rows if item["variant"] == VARIANT_BASELINE), rows[0])
+ fixtures_out.append(
+ {
+ "id": fixture["id"],
+ "class": fixture["class"],
+ "source": fixture["source"],
+ "variants": rows,
+ "human_blind": _human_blind_pair(current, baseline),
+ }
+ )
+
+ profile_ab_report = None
+ if profile_ab:
+ source = next(item["source"] for item in FIXTURES if item["id"] == "outstanding_event")
+ ctx_a = fixture_context(source, writing_profile=PROFILE_A)
+ ctx_b = fixture_context(source, writing_profile=PROFILE_B)
+ current_template = templates[VARIANT_CURRENT]
if live:
if not profile_id:
raise SystemExit("--live requires --profile-id")
- row = run_variant(name, template, context, profile_id=profile_id, live=True)
+ row_a = run_variant("profile_a", current_template, ctx_a, profile_id=profile_id, live=True)
+ row_b = run_variant("profile_b", current_template, ctx_b, profile_id=profile_id, live=True)
else:
- rendered = resolve_template(template, context)
- started = time.perf_counter()
- content = _fake_complete("journal_generate", rendered)
- row = {
- "variant": name,
- "live": False,
- "content": content,
- "prompt_tokens": None,
- "completion_tokens": None,
- "total_tokens": None,
- "cost": None,
- "runtime_ms": int((time.perf_counter() - started) * 1000),
- "model": "fake",
- "fake_provider": True,
- }
- row["scores"] = score_output(
- source,
- row["content"],
- profile=context.get("writing_profile") or "",
- typos=SYNTHETIC_TYPOS,
- )
- row["source"] = "synthetic"
- rows.append(row)
+ row_a = _offline_row("profile_a", current_template, ctx_a)
+ row_b = _offline_row("profile_b", current_template, ctx_b)
+ rendered_a = row_a.get("rendered") or ""
+ rendered_b = row_b.get("rendered") or ""
+ profile_ab_report = {
+ "same_facts": ctx_a["reconstruction"] == ctx_b["reconstruction"],
+ "prompts_differ": (PROFILE_A in rendered_a and PROFILE_B in rendered_b and PROFILE_A not in rendered_b)
+ if not live
+ else row_a.get("content") != row_b.get("content"),
+ "profile_a_in_prompt": PROFILE_A[:20] in rendered_a if not live else None,
+ "profile_b_in_prompt": PROFILE_B[:20] in rendered_b if not live else None,
+ "human_blind": _human_blind_pair(row_a, row_b),
+ "note": (
+ "Offline: beweist verschiedene Stilvorgaben im gerenderten Prompt, nicht Live-Prosa."
+ if not live
+ else "Live-A/B über das Privacy Gateway. Ob Rhythmus und Wortwahl divergieren, bewertet ein Mensch."
+ ),
+ }
+
return {
"live": live,
"live_quality_confirmed": False,
+ "winner_declared": False,
"note": (
"Fake-Provider-Lauf: beweist den Vergleichsvertrag, nicht echte Modellprosa."
if not live
- else "Live-Lauf über das Privacy Gateway. Qualitative Bewertung bleibt manuell."
+ else "Live-Lauf über das Privacy Gateway. Qualitative Bewertung bleibt manuell. Keine automatische Siegeraussage."
),
"source_kind": "synthetic",
- "variants": rows,
+ "fixtures": fixtures_out,
+ "profile_ab": profile_ab_report,
}
@@ -233,6 +415,7 @@ def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Journal narration quality comparison (opt-in).")
parser.add_argument("--live", action="store_true", help="Call the configured generate provider. Costs money.")
parser.add_argument("--profile-id", help="Required with --live. Uses the Privacy Gateway.")
+ parser.add_argument("--profile-ab", action="store_true", help="Compare two synthetic writing profiles.")
parser.add_argument("--json", action="store_true", help="Print JSON instead of text.")
args = parser.parse_args(argv)
if args.live:
@@ -252,20 +435,31 @@ def main(argv: list[str] | None = None) -> int:
from db import init_db
init_db()
- report = compare_synthetic(live=bool(args.live), profile_id=args.profile_id)
+ report = compare_synthetic(live=bool(args.live), profile_id=args.profile_id, profile_ab=bool(args.profile_ab))
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0
print(report["note"])
if not report["live"]:
- print("Live-Qualität: noch nicht bestätigt.")
- for item in report["variants"]:
- scores = item["scores"]
- print(
- f"{item['variant']}: similarity={scores['lexical_similarity']} "
- f"keep={scores['fact_token_keep']} typos_left={scores['spelling_typos_remaining']} "
- f"tokens={item.get('total_tokens')} cost={item.get('cost')} ms={item['runtime_ms']}"
- )
+ print("Live-Qualität: noch nicht bestätigt. Keine Siegeraussage.")
+ for fixture in report["fixtures"]:
+ print(f"\n[{fixture['id']} / {fixture['class']}]")
+ for item in fixture["variants"]:
+ scores = item["scores"]
+ print(
+ f" {item['variant']}: similarity={scores['lexical_similarity']} "
+ f"keep={scores['fact_token_keep']} typos_left={scores['spelling_typos_remaining']} "
+ f"incomplete={scores['incomplete_syntax']} tokens={item.get('total_tokens')} "
+ f"cost={item.get('cost')} ms={item['runtime_ms']}"
+ )
+ blind = fixture["human_blind"]
+ print(" Blindvergleich: Prompt 1 vs Prompt 2 (Mapping nur im JSON).")
+ print(f" {blind['instruction']}")
+ if report.get("profile_ab"):
+ ab = report["profile_ab"]
+ print("\n[profile_ab]")
+ print(f" same_facts={ab['same_facts']} prompts_differ={ab['prompts_differ']}")
+ print(f" {ab['note']}")
return 0
diff --git a/backend/journal_generate.py b/backend/journal_generate.py
index 495b7bb..85ae4af 100644
--- a/backend/journal_generate.py
+++ b/backend/journal_generate.py
@@ -7,15 +7,25 @@ from context_builder import assemble_text, build_internal_context
from dialogue_store import StoreError, list_conversations_for_day, list_messages
from engine import EngineError, execute_prompt, load_active_prompt, preview_prompt
from journal_policy import require_explicit_generate, source_conversation_ids
-from identity_store import is_maskable_label, list_mappings
+from identity_store import is_maskable_label, list_mappings, mapping_spellings
from journal_body import clean_title
from journal_editorial import (
- choose_editorial_mode,
- editorial_instructions,
format_style_examples,
+ incomplete_syntax_markers,
lexical_similarity,
narration_sources_text,
select_journal_style_examples,
+ style_example_diagnostics,
+ writing_profile_trace,
+)
+from journal_generation_policy import (
+ assert_journal_prompt_contract,
+ assert_template_resolved,
+ compile_selection,
+ draft_snapshot,
+ mark_guidelines_used,
+ policy_trace,
+ resolve_run_selection,
)
from journal_reconstruct import (
assign_source_ids,
@@ -42,6 +52,9 @@ from providers import generate_provider
from retrieval import retrieve
from writing_profile_store import remember_dialogue_style
+JOURNAL_NOT_ACCEPTED = "journal_generation_not_accepted"
+JOURNAL_NOT_ACCEPTED_MESSAGE = "Generierung nicht übernommen."
+
IDENTITY_PLACEHOLDER = re.compile(
r"\[\[\s*(?:SELF|PERSON:[^\]]+|PLACE:[^\]]+|ORG:[^\]]+|PROJECT:[^\]]+|…|\.{2,})\s*\]\]",
@@ -70,7 +83,7 @@ def _split_title(content: str) -> tuple[str, str]:
def _local_narration(reconstruction: dict) -> tuple[str, str]:
- """Fail-closed draft from verified local sources. No leaking model text."""
+ """Local source preview only. Never stored as a generated journal draft."""
parts = [str(part).strip() for part in claim_texts(reconstruction) if str(part).strip()]
body = "\n\n".join(parts)
return "Ein Tag", body
@@ -85,6 +98,8 @@ def unattested_journal_content(
"""Journal-only: historical names not in the sources are unattested facts, not a privacy leak.
Title and body are checked together. Placeholders must stay consistent across both.
+ Attestation is token-based: any attested spelling of a token (canonical or alias)
+ allows every known spelling of that same token after local demasking.
"""
body = text or ""
allowed = {canonical_token(token).upper().replace(" ", "") for token in (active_tokens or [])}
@@ -96,30 +111,118 @@ def unattested_journal_content(
if token in GENERIC_PLACEHOLDER_INNER or token not in allowed:
return "unattested_placeholder"
sources = "\n".join(source_user or [])
+ spellings_by_token: dict[str, list[str]] = {}
for item in mappings or []:
- label = (item.get("local_label") or "").strip()
- token = (item.get("token") or "").strip()
- if not label or not token or not is_maskable_label(label):
+ token = canonical_token(item.get("token") or "").upper().replace(" ", "")
+ if not token:
continue
- if identity_occurrence_count(sources, label, token) > 0:
+ bucket = spellings_by_token.setdefault(token, [])
+ seen = {label.casefold() for label in bucket}
+ for label in mapping_spellings(item):
+ if not label or label.casefold() in seen:
+ continue
+ seen.add(label.casefold())
+ bucket.append(label)
+ attested: set[str] = set()
+ for token, labels in spellings_by_token.items():
+ for label in labels:
+ if identity_occurrence_count(sources, label, token) > 0:
+ attested.add(token)
+ break
+ for token, labels in spellings_by_token.items():
+ if token in attested:
continue
- for match in identity_label_pattern(label).finditer(body):
- if is_identity_mention(body, match.start(), match.end(), token):
- return "unattested_identity"
+ for label in labels:
+ if not is_maskable_label(label):
+ continue
+ for match in identity_label_pattern(label).finditer(body):
+ if is_identity_mention(body, match.start(), match.end(), token):
+ return "unattested_identity"
return None
-def _identity_leak_result(purpose: str, exc: EngineError) -> dict:
- diag = getattr(exc, "diagnostics", None) or {}
+def _reject_generation(
+ *,
+ reason: str,
+ diagnostics: dict | None = None,
+ source_preview: str = "",
+ extra: dict | None = None,
+) -> None:
+ diag = dict(diagnostics or {})
+ trace = dict(diag.get("trace") or {})
+ extra = dict(extra or {})
+ trace.update(
+ {
+ "purpose": "journal_generate",
+ "prompt_slug": extra.get("prompt_slug") or trace.get("prompt_slug") or "mvp.journal_generate",
+ "model_text_accepted": False,
+ "narration_source": "not_accepted",
+ "abort_reason": reason,
+ "source_preview": source_preview,
+ **extra,
+ }
+ )
+ diag["trace"] = trace
+ diag["log"] = extra.get("log") or diag.get("log") or trace.get("log") or []
+ diag["model_text_accepted"] = False
+ diag["abort_reason"] = reason
+ diag["provenance_decision"] = extra.get("provenance_decision") or diag.get("provenance_decision")
+ raise EngineError(
+ JOURNAL_NOT_ACCEPTED,
+ JOURNAL_NOT_ACCEPTED_MESSAGE,
+ 409,
+ diagnostics=diag,
+ )
+
+
+def _compose_journal_trace(
+ reconstruct_result: dict,
+ narrate_result: dict,
+ *,
+ run_log: list[dict],
+ narrate_prompt: dict,
+ profile_meta: dict,
+ style_meta: dict,
+ dropped: list[str],
+ extra: dict | None = None,
+) -> dict:
+ reconstruct_trace = _stage_trace(reconstruct_result, "local_source_artifact")
+ reconstruct_trace.setdefault("purpose", "local_source_artifact")
+ reconstruct_trace.setdefault("status", "local_ok")
+ reconstruct_trace.setdefault("stage1", "local_ok")
+ narrate_trace = _stage_trace(narrate_result, "journal_generate")
+ narrate_trace.setdefault("prompt_slug", narrate_prompt.get("slug") or "mvp.journal_generate")
+ narrate_trace["prompt_revision"] = (
+ narrate_trace.get("prompt_revision")
+ or narrate_prompt.get("seed_revision")
+ or ""
+ )
+ narrate_trace["writing_profile"] = profile_meta
+ narrate_trace["style_examples"] = {**style_meta, "dropped": "style_examples" in dropped}
+ narrate_trace["dropped_optional_blocks"] = dropped
+ diag = narrate_result.get("diagnostics") or {}
+ if diag.get("generate_ms") is not None:
+ narrate_trace["generate_ms"] = diag.get("generate_ms")
+ if diag.get("prompt_revision"):
+ narrate_trace["prompt_revision"] = diag.get("prompt_revision")
+ if diag.get("generate_calls") is not None:
+ narrate_trace.setdefault("generate_calls", diag.get("generate_calls"))
+ if diag.get("model"):
+ narrate_trace.setdefault("model", diag.get("model"))
+ if diag.get("provider"):
+ narrate_trace.setdefault("provider", diag.get("provider"))
+ if diag.get("completion_tokens") is not None:
+ narrate_trace.setdefault("completion_tokens", diag.get("completion_tokens"))
+ if extra:
+ narrate_trace.update(extra)
return {
- "content": "",
- "trace": {
- "purpose": purpose,
- "guard": "identity_leak_blocked",
- "log": list(diag.get("log") or []),
- "response_validation_retry": diag.get("response_validation_retry"),
- },
- "diagnostics": diag,
+ **narrate_trace,
+ "prompt_revision": narrate_trace.get("prompt_revision"),
+ "writing_profile": profile_meta,
+ "style_examples": narrate_trace.get("style_examples"),
+ "dropped_optional_blocks": dropped,
+ "log": run_log,
+ "stages": [reconstruct_trace, narrate_trace],
}
@@ -251,6 +354,8 @@ def generate_draft(
conversation_ids: list[str] | None = None,
include_existing: bool = False,
explicit: bool = True,
+ generation_selection: dict | None = None,
+ remember_generation_selection: bool = False,
) -> dict:
require_explicit_generate(explicit)
day = get_day(profile_id, journal_day_id)
@@ -261,6 +366,13 @@ def generate_draft(
)
remember_dialogue_style(profile_id, exclude_conversation_ids=selected)
existing_text = _existing_text(profile_id, journal_day_id) if include_existing else ""
+ selection_ids, persist_meta = resolve_run_selection(
+ profile_id,
+ generation_selection,
+ remember_generation_selection,
+ )
+ narrate_prompt = load_active_prompt("mvp.journal_generate")
+ assert_journal_prompt_contract(narrate_prompt.get("template") or "")
config = generate_provider()
if not config:
@@ -294,12 +406,19 @@ def generate_draft(
for message in assign_source_ids(source_messages)
if message.get("role") == "user" and (message.get("body") or "").strip()
]
- editorial_mode = choose_editorial_mode(source_user)
+ compiled_policy = compile_selection(selection_ids)
+ policy_meta = policy_trace(
+ compiled_policy,
+ source=persist_meta["source"],
+ remembered=persist_meta["remembered"],
+ )
+ profile_meta = writing_profile_trace(profile_id)
style_example_rows = select_journal_style_examples(
profile_id,
exclude_dates=[day.get("calendar_date") or ""],
)
style_examples = format_style_examples(style_example_rows)
+ style_meta = style_example_diagnostics(style_example_rows)
run_log: list[dict] = []
_event(
run_log,
@@ -309,14 +428,19 @@ def generate_draft(
coverage=reconstruction.get("coverage") or "all_selected_sources",
status="local_ok",
source_count=len(source_user),
- editorial_mode=editorial_mode,
+ writing_profile_present=profile_meta.get("present"),
+ style_example_count=style_meta.get("count"),
+ generation_selection_source=policy_meta.get("source"),
+ generation_selection_keys=",".join(
+ f"{slot}:{policy_meta.get('keys', {}).get(slot)}"
+ for slot in ("transformation", "detail", "voice", "narrative")
+ ),
)
reconstruct_result = {
"trace": _local_stage_trace(reconstruction, source_count=len(source_user)),
"content": reconstruction_text(reconstruction),
}
- narrate_prompt = load_active_prompt("mvp.journal_generate")
narrate_context = build_internal_context(
profile_id,
space_id=day["space_id"],
@@ -328,8 +452,10 @@ def generate_draft(
conversation_id=selected[0] if selected else None,
reconstruction=narration_sources_text(reconstruction),
style_examples=style_examples,
- editorial_mode=editorial_mode,
- editorial_instructions=editorial_instructions(editorial_mode),
+ transformation_instructions=compiled_policy.instructions["transformation_instructions"],
+ detail_instructions=compiled_policy.instructions["detail_instructions"],
+ voice_instructions=compiled_policy.instructions["voice_instructions"],
+ narrative_instructions=compiled_policy.instructions["narrative_instructions"],
)
assembled = assemble_text(narrate_context)
try:
@@ -345,10 +471,13 @@ def generate_draft(
_raise_budget(exc)
if dropped:
_event(run_log, "journal_generate", "budget_pack", dropped=",".join(dropped))
- shape_source = "model"
mappings = list_mappings(profile_id)
+ preview_title, preview_body = _local_narration(reconstruction)
+ source_preview = f"{preview_title}\n\n{preview_body}".strip()
narrate_result: dict = {}
try:
+ rendered_preview = preview_prompt(narrate_prompt, assembled)
+ assert_template_resolved(rendered_preview.get("rendered") or "")
narrate_result = execute_prompt(
narrate_prompt,
profile_id,
@@ -372,44 +501,94 @@ def generate_draft(
combined = f"{title}\n\n{body}".strip()
unattested = unattested_journal_content(combined, source_user, mappings, active_tokens)
if unattested:
- title, body = _local_narration(reconstruction)
- shape_source = "fallback"
- narrate_result = {
- **narrate_result,
- "content": f"{title}\n\n{body}".strip(),
- "trace": {
- **(narrate_result.get("trace") or {}),
- "guard": "unattested_content_blocked",
- "reason": unattested,
- },
- }
_event(
run_log,
"journal_generate",
"narration_result",
- status="local_fallback",
+ status="not_accepted",
reason=unattested,
)
- else:
- _event(
- run_log,
- "journal_generate",
- "narration_result",
- status="model",
- editorial_mode=editorial_mode,
- lexical_similarity=lexical_similarity("\n".join(source_user), body),
+ bundle = _compose_journal_trace(
+ reconstruct_result,
+ narrate_result,
+ run_log=run_log,
+ narrate_prompt=narrate_prompt,
+ profile_meta=profile_meta,
+ style_meta=style_meta,
+ dropped=dropped,
+ extra={
+ "narration_source": "not_accepted",
+ "model_text_accepted": False,
+ "provenance_decision": unattested,
+ "abort_reason": unattested,
+ "source_preview": source_preview,
+ "generation_selection": policy_meta,
+ },
)
+ _reject_generation(
+ reason=unattested,
+ diagnostics={
+ **(narrate_result.get("diagnostics") or {}),
+ "trace": bundle,
+ "log": run_log,
+ "provenance_decision": unattested,
+ },
+ source_preview=source_preview,
+ extra=bundle,
+ )
+ _event(
+ run_log,
+ "journal_generate",
+ "narration_result",
+ status="model",
+ lexical_similarity=lexical_similarity("\n".join(source_user), body),
+ incomplete_syntax=incomplete_syntax_markers(body),
+ writing_profile_present=profile_meta.get("present"),
+ )
except EngineError as exc:
+ if exc.code == JOURNAL_NOT_ACCEPTED:
+ raise
_stamp_log(run_log, "journal_generate", _log_items(exc))
- if exc.code != "response_validation_failed":
- diag = dict(exc.diagnostics or {})
- diag["log"] = run_log
+ diag = dict(exc.diagnostics or {})
+ generate_called = bool(diag.get("generate_called") or diag.get("generate_calls"))
+ failed_result = {
+ "diagnostics": diag,
+ "trace": dict(diag.get("trace") or {}),
+ "content": "",
+ }
+ bundle = _compose_journal_trace(
+ reconstruct_result,
+ failed_result,
+ run_log=run_log,
+ narrate_prompt=narrate_prompt,
+ profile_meta=profile_meta,
+ style_meta=style_meta,
+ dropped=dropped,
+ extra={
+ "narration_source": "not_accepted",
+ "model_text_accepted": False,
+ "abort_reason": exc.code,
+ "source_preview": source_preview,
+ "generation_selection": policy_meta,
+ },
+ )
+ diag["log"] = run_log
+ diag["trace"] = bundle
+ if not generate_called:
raise EngineError(exc.code, exc.message, exc.status_code, diag) from exc
- title, body = _local_narration(reconstruction)
- shape_source = "fallback"
- narrate_result = _identity_leak_result("journal_generate", exc)
- narrate_result["content"] = f"{title}\n\n{body}".strip()
- _event(run_log, "journal_generate", "narration_result", status="local_fallback", reason="identity_leak_blocked")
+ _event(
+ run_log,
+ "journal_generate",
+ "narration_result",
+ status="not_accepted",
+ reason=exc.code,
+ )
+ _reject_generation(
+ reason=exc.code,
+ diagnostics=diag,
+ source_preview=source_preview,
+ extra=bundle,
+ )
person_labels = [
(item.get("local_label") or "").strip()
for item in mappings
@@ -421,9 +600,24 @@ def generate_draft(
body,
source_user,
person_labels=person_labels,
- source=shape_source,
+ source="model",
)
before_entries = {item["id"]: item.get("current_version_id") for item in current_entries(profile_id, journal_day_id)}
+ model = (
+ ((narrate_result.get("diagnostics") or {}).get("actual_model"))
+ or ((narrate_result.get("trace") or {}).get("model"))
+ or narrate_result.get("provider")
+ or ""
+ )
+ snapshot = draft_snapshot(compiled_policy, prompt=narrate_prompt, model=str(model or ""))
+ mark_guidelines_used(
+ [
+ compiled_policy.ids["transformation"],
+ compiled_policy.ids["detail"],
+ compiled_policy.ids["voice"],
+ compiled_policy.ids["narrative"],
+ ]
+ )
draft = insert_draft(
profile_id,
journal_day_id,
@@ -431,25 +625,30 @@ def generate_draft(
body=body,
source_conversation_ids=selected,
source_message_ids=_message_ids(profile_id, selected),
+ generation_snapshot=snapshot,
)
after_entries = current_entries(profile_id, journal_day_id)
for item in after_entries:
previous = before_entries.get(item["id"])
if previous is not None and previous != item.get("current_version_id"):
raise StoreError("policy_violation", "Generate darf die Nutzerfassung nicht verändern", 500)
- reconstruct_trace = _stage_trace(reconstruct_result, "local_source_artifact")
- reconstruct_trace.setdefault("purpose", "local_source_artifact")
- reconstruct_trace.setdefault("status", "local_ok")
- reconstruct_trace.setdefault("stage1", "local_ok")
- narrate_trace = _stage_trace(narrate_result, "journal_generate")
- narrate_trace["editorial_mode"] = editorial_mode
- if shape_source == "model":
- narrate_trace["lexical_similarity"] = lexical_similarity("\n".join(source_user), body)
+ bundle = _compose_journal_trace(
+ reconstruct_result,
+ narrate_result,
+ run_log=run_log,
+ narrate_prompt=narrate_prompt,
+ profile_meta=profile_meta,
+ style_meta=style_meta,
+ dropped=dropped,
+ extra={
+ "narration_source": "model",
+ "model_text_accepted": True,
+ "provenance_decision": "accepted",
+ "lexical_similarity": lexical_similarity("\n".join(source_user), body),
+ "incomplete_syntax": incomplete_syntax_markers(body),
+ "generation_selection": policy_meta,
+ },
+ )
draft["run_log"] = run_log
- draft["trace"] = {
- **narrate_trace,
- "editorial_mode": editorial_mode,
- "log": run_log,
- "stages": [reconstruct_trace, narrate_trace],
- }
+ draft["trace"] = bundle
return draft
diff --git a/backend/journal_generation_policy.py b/backend/journal_generation_policy.py
new file mode 100644
index 0000000..b5c029a
--- /dev/null
+++ b/backend/journal_generation_policy.py
@@ -0,0 +1,864 @@
+"""Named, versioned journal generation guidelines. Not model temperature.
+
+Four independent dimensions are selected by ID. Instruction text lives in
+generation_guidelines, seeded from JSON. This module selects, validates and
+composes. It does not own prompt wording and has no numeric 0–100 path.
+"""
+from __future__ import annotations
+
+import json
+import re
+import uuid
+from dataclasses import dataclass
+from pathlib import Path
+
+from db import get_db, row_to_dict
+from journal_policy import PolicyError
+from placeholders import CONTEXT_PATTERN
+
+PURPOSE_JOURNAL = "journal_generate"
+USER_SLOTS = ("transformation", "detail", "voice", "narrative")
+ALLOWED_SLOTS = USER_SLOTS
+LEGACY_SOURCE_MODE_SLOT = "source_mode"
+SELECTION_KEYS = (
+ "transformation_id",
+ "detail_id",
+ "voice_id",
+ "narrative_id",
+)
+SLOT_TO_ID_KEY = {
+ "transformation": "transformation_id",
+ "detail": "detail_id",
+ "voice": "voice_id",
+ "narrative": "narrative_id",
+}
+ID_KEY_TO_SLOT = {value: key for key, value in SLOT_TO_ID_KEY.items()}
+SLOT_INSTRUCTION_KEYS = {
+ "transformation": "transformation_instructions",
+ "detail": "detail_instructions",
+ "voice": "voice_instructions",
+ "narrative": "narrative_instructions",
+}
+REQUIRED_JOURNAL_PLACEHOLDERS = (
+ "transformation_instructions",
+ "detail_instructions",
+ "voice_instructions",
+ "narrative_instructions",
+ "writing_profile",
+ "style_examples",
+ "reconstruction",
+ "existing_text",
+)
+RETIRED_JOURNAL_PLACEHOLDERS = (
+ "source_mode_instructions",
+ "editorial_mode",
+ "editorial_instructions",
+)
+STATUS_DRAFT = "draft"
+STATUS_ACTIVE = "active"
+STATUS_ARCHIVED = "archived"
+STATUSES = (STATUS_DRAFT, STATUS_ACTIVE, STATUS_ARCHIVED)
+MAX_INSTRUCTION_CHARS = 1000
+MAX_LABEL_CHARS = 80
+MAX_SUMMARY_CHARS = 160
+KEY_RE = re.compile(r"^[a-z][a-z0-9_]{0,40}$")
+SEED_PATH = Path(__file__).resolve().parent / "config" / "generation_instructions.seed.json"
+PUBLIC_FIELDS = (
+ "id",
+ "purpose",
+ "slot",
+ "guideline_key",
+ "label",
+ "summary",
+ "sort_order",
+ "status",
+ "revision",
+ "cloned_from",
+ "is_default",
+ "is_system_seed",
+ "seed_revision",
+ "used_at",
+ "created",
+ "updated",
+)
+
+
+class GenerationPolicyError(PolicyError):
+ def __init__(
+ self,
+ message: str,
+ *,
+ code: str = "invalid_generation_selection",
+ status_code: int = 400,
+ ):
+ super().__init__(code, message, status_code)
+
+
+class CatalogError(GenerationPolicyError):
+ def __init__(self, message: str, *, code: str = "generation_policy_invalid", status_code: int = 409):
+ super().__init__(message, code=code, status_code=status_code)
+
+
+@dataclass(frozen=True)
+class CompiledPolicy:
+ ids: dict[str, str]
+ keys: dict[str, str]
+ labels: dict[str, str]
+ summaries: dict[str, str]
+ revisions: dict[str, int]
+ instructions: dict[str, str]
+ seed_revision: str
+
+
+def load_seed_document() -> dict:
+ return json.loads(SEED_PATH.read_text(encoding="utf-8"))
+
+
+def _as_bool(raw) -> bool:
+ return bool(int(raw)) if not isinstance(raw, bool) else raw
+
+
+def _limits() -> tuple[int, int, int]:
+ seed = load_seed_document()
+ return (
+ int(seed.get("max_instruction_chars") or MAX_INSTRUCTION_CHARS),
+ int(seed.get("max_label_chars") or MAX_LABEL_CHARS),
+ int(seed.get("max_summary_chars") or MAX_SUMMARY_CHARS),
+ )
+
+
+def _clean_text(raw, field: str, *, max_chars: int, allow_empty: bool = False) -> str:
+ text = (raw or "").strip()
+ if not text and not allow_empty:
+ raise CatalogError(f"{field} darf nicht leer sein.", code="invalid_generation_guideline", status_code=400)
+ if len(text) > max_chars:
+ raise CatalogError(f"{field} darf höchstens {max_chars} Zeichen haben.", code="invalid_generation_guideline", status_code=400)
+ return text
+
+
+def _clean_key(raw) -> str:
+ key = (raw or "").strip()
+ if not KEY_RE.match(key):
+ raise CatalogError(
+ "guideline_key muss aus Kleinbuchstaben, Ziffern und Unterstrich bestehen.",
+ code="invalid_generation_guideline",
+ status_code=400,
+ )
+ return key
+
+
+def _row(item: dict | None, *, include_instruction: bool = False) -> dict | None:
+ if not item:
+ return None
+ payload = {field: item.get(field) for field in PUBLIC_FIELDS}
+ payload["is_default"] = _as_bool(item.get("is_default", 0))
+ payload["is_system_seed"] = _as_bool(item.get("is_system_seed", 0))
+ payload["revision"] = int(item.get("revision") or 1)
+ payload["sort_order"] = int(item.get("sort_order") or 0)
+ payload["guideline_key"] = item.get("guideline_key") or ""
+ if include_instruction:
+ payload["instruction"] = item.get("instruction") or ""
+ payload["seed_id"] = item.get("seed_id") or ""
+ return payload
+
+
+def _fetch(guideline_id: str) -> dict | None:
+ with get_db() as conn:
+ return row_to_dict(
+ conn.execute("SELECT * FROM generation_guidelines WHERE id = ?", (guideline_id,)).fetchone()
+ )
+
+
+def get_guideline(guideline_id: str, *, include_instruction: bool = True) -> dict:
+ row = _fetch(guideline_id)
+ if not row:
+ raise CatalogError("Ausprägung nicht gefunden.", code="guideline_missing", status_code=404)
+ return _row(row, include_instruction=include_instruction)
+
+
+def list_guidelines(
+ purpose: str = PURPOSE_JOURNAL,
+ *,
+ slot: str | None = None,
+ statuses: tuple[str, ...] | None = None,
+ include_instruction: bool = False,
+) -> list[dict]:
+ query = "SELECT * FROM generation_guidelines WHERE purpose = ?"
+ params: list = [purpose]
+ if slot:
+ query += " AND slot = ?"
+ params.append(slot)
+ if statuses:
+ query += " AND status IN (" + ",".join("?" for _ in statuses) + ")"
+ params.extend(statuses)
+ query += " ORDER BY slot, sort_order, revision, label"
+ with get_db() as conn:
+ rows = [row_to_dict(row) for row in conn.execute(query, params).fetchall()]
+ return [_row(row, include_instruction=include_instruction) for row in rows]
+
+
+def overview_payload(purpose: str = PURPOSE_JOURNAL) -> dict:
+ seed = load_seed_document()
+ items = list_guidelines(purpose)
+ slots = {slot: [item for item in items if item["slot"] == slot] for slot in ALLOWED_SLOTS}
+ return {
+ "purpose": purpose,
+ "seed_revision": seed.get("seed_revision") or "",
+ "max_instruction_chars": int(seed.get("max_instruction_chars") or MAX_INSTRUCTION_CHARS),
+ "max_label_chars": int(seed.get("max_label_chars") or MAX_LABEL_CHARS),
+ "max_summary_chars": int(seed.get("max_summary_chars") or MAX_SUMMARY_CHARS),
+ "slots": slots,
+ }
+
+
+def active_user_options(purpose: str = PURPOSE_JOURNAL) -> dict[str, list[dict]]:
+ items = list_guidelines(purpose, statuses=(STATUS_ACTIVE,))
+ return {
+ slot: [
+ {
+ "id": item["id"],
+ "key": item["guideline_key"],
+ "label": item["label"],
+ "summary": item["summary"],
+ "revision": item["revision"],
+ "is_default": item["is_default"],
+ }
+ for item in items
+ if item["slot"] == slot
+ ]
+ for slot in USER_SLOTS
+ }
+
+
+def default_selection_ids(purpose: str = PURPOSE_JOURNAL) -> dict[str, str]:
+ items = list_guidelines(purpose, statuses=(STATUS_ACTIVE,))
+ chosen = {}
+ for slot in USER_SLOTS:
+ slot_items = [item for item in items if item["slot"] == slot]
+ if not slot_items:
+ raise CatalogError(f"{slot} hat keine aktive Ausprägung.")
+ preferred = next((item for item in slot_items if item["is_default"]), slot_items[0])
+ chosen[SLOT_TO_ID_KEY[slot]] = preferred["id"]
+ return chosen
+
+
+def _require_active_for_run(row: dict, slot: str) -> dict:
+ if not row:
+ raise GenerationPolicyError(f"{slot} ist unbekannt.")
+ if row.get("slot") != slot:
+ raise GenerationPolicyError(f"{slot} verweist auf die falsche Dimension.")
+ if row.get("status") != STATUS_ACTIVE:
+ raise GenerationPolicyError(
+ f"{slot} ist nicht für neue Läufe verfügbar.",
+ code="invalid_generation_selection",
+ )
+ if not (row.get("instruction") or "").strip():
+ raise CatalogError(f"{slot} hat keine Anweisung.")
+ return row
+
+
+def validate_selection(raw, *, purpose: str = PURPOSE_JOURNAL) -> dict[str, dict]:
+ if not isinstance(raw, dict):
+ raise GenerationPolicyError("generation_selection muss die vier Ausprägungs-IDs enthalten.")
+ unknown = sorted(set(raw) - set(SELECTION_KEYS))
+ if unknown:
+ raise GenerationPolicyError("Unbekannte Auswahl: " + ", ".join(unknown))
+ missing = [key for key in SELECTION_KEYS if not (raw.get(key) or "").strip()]
+ if missing:
+ raise GenerationPolicyError("generation_selection ist unvollständig: " + ", ".join(missing))
+ chosen = {}
+ for key in SELECTION_KEYS:
+ slot = ID_KEY_TO_SLOT[key]
+ row = _fetch(str(raw[key]).strip())
+ chosen[slot] = _require_active_for_run(row, slot)
+ if (row.get("purpose") or purpose) != purpose:
+ raise GenerationPolicyError(f"{slot} gehört nicht zu diesem Zweck.")
+ return chosen
+
+
+def compile_selection(
+ selection: dict[str, str],
+ *,
+ purpose: str = PURPOSE_JOURNAL,
+) -> CompiledPolicy:
+ chosen = validate_selection(selection, purpose=purpose)
+ instructions = {
+ SLOT_INSTRUCTION_KEYS[slot]: chosen[slot]["instruction"] for slot in USER_SLOTS
+ }
+ revision = ""
+ for row in chosen.values():
+ revision = row.get("seed_revision") or revision
+ return CompiledPolicy(
+ ids={slot: chosen[slot]["id"] for slot in USER_SLOTS},
+ keys={slot: chosen[slot]["guideline_key"] for slot in USER_SLOTS},
+ labels={slot: chosen[slot]["label"] for slot in USER_SLOTS},
+ summaries={slot: chosen[slot].get("summary") or "" for slot in USER_SLOTS},
+ revisions={slot: int(chosen[slot].get("revision") or 1) for slot in USER_SLOTS},
+ instructions=instructions,
+ seed_revision=revision,
+ )
+
+
+def policy_trace(
+ compiled: CompiledPolicy,
+ *,
+ source: str,
+ remembered: bool,
+) -> dict:
+ return {
+ "source": source,
+ "remembered": remembered,
+ "ids": dict(compiled.ids),
+ "keys": dict(compiled.keys),
+ "labels": dict(compiled.labels),
+ "revisions": dict(compiled.revisions),
+ "seed_revision": compiled.seed_revision,
+ }
+
+
+def draft_snapshot(compiled: CompiledPolicy, *, prompt: dict | None = None, model: str = "") -> dict:
+ """Persistable run snapshot. No prompt bodies or instruction texts."""
+ return {
+ "transformation": {
+ "id": compiled.ids["transformation"],
+ "key": compiled.keys["transformation"],
+ "label": compiled.labels["transformation"],
+ "revision": compiled.revisions["transformation"],
+ },
+ "detail": {
+ "id": compiled.ids["detail"],
+ "key": compiled.keys["detail"],
+ "label": compiled.labels["detail"],
+ "revision": compiled.revisions["detail"],
+ },
+ "voice": {
+ "id": compiled.ids["voice"],
+ "key": compiled.keys["voice"],
+ "label": compiled.labels["voice"],
+ "revision": compiled.revisions["voice"],
+ },
+ "narrative": {
+ "id": compiled.ids["narrative"],
+ "key": compiled.keys["narrative"],
+ "label": compiled.labels["narrative"],
+ "revision": compiled.revisions["narrative"],
+ },
+ "prompt_slug": (prompt or {}).get("slug") or "",
+ "prompt_revision": (prompt or {}).get("seed_revision") or "",
+ "model": model or "",
+ }
+
+
+def snapshot_summary(snapshot: dict | None) -> str:
+ data = snapshot or {}
+ labels = [
+ (data.get("transformation") or {}).get("label") or "",
+ (data.get("detail") or {}).get("label") or "",
+ (data.get("voice") or {}).get("label") or "",
+ (data.get("narrative") or {}).get("label") or "",
+ ]
+ labels = [item for item in labels if item]
+ if not labels:
+ return ""
+ line = " · ".join(item[:1].upper() + item[1:] if item else item for item in labels)
+ return f"Erzeugt mit:\n{line}"
+
+
+def assert_journal_prompt_contract(template: str) -> None:
+ keys = CONTEXT_PATTERN.findall(template or "")
+ retired = [key for key in RETIRED_JOURNAL_PLACEHOLDERS if key in keys]
+ if retired:
+ shown = ", ".join(f"{{{{{key}}}}}" for key in retired)
+ raise CatalogError(
+ "Dieser Journal-Prompt verwendet den veralteten Platzhalter "
+ + shown
+ + ". Die Unterscheidung zwischen Fließtext- und Stichpunktmodus ist entfallen. "
+ "Bitte den Prompt unter Admin → Prompts aktualisieren.",
+ code="prompt_contract_incompatible",
+ )
+ missing = [key for key in REQUIRED_JOURNAL_PLACEHOLDERS if key not in keys]
+ if missing:
+ shown = ", ".join(f"{{{{{key}}}}}" for key in missing)
+ raise CatalogError(
+ "Dieser Journal-Prompt erfüllt den aktuellen Vertrag nicht. Es fehlen: "
+ + shown
+ + ". Bitte den Prompt unter Admin → Prompts aktualisieren.",
+ code="prompt_contract_incompatible",
+ )
+
+
+def assert_template_resolved(rendered: str) -> None:
+ leftover = CONTEXT_PATTERN.findall(rendered or "")
+ if leftover:
+ raise CatalogError(
+ "Promptplatzhalter konnten nicht aufgelöst werden: "
+ + ", ".join(f"{{{{{key}}}}}" for key in leftover),
+ code="unresolved_placeholder",
+ )
+
+
+def load_selection(profile_id: str) -> dict[str, str] | None:
+ with get_db() as conn:
+ row = row_to_dict(
+ conn.execute(
+ """
+ SELECT transformation_id, detail_id, voice_id, narrative_id, updated
+ FROM journal_generation_selection
+ WHERE profile_id = ?
+ """,
+ (profile_id,),
+ ).fetchone()
+ )
+ if not row:
+ return None
+ values = {key: row[key] for key in SELECTION_KEYS}
+ values["updated"] = row.get("updated") or ""
+ return values
+
+
+def save_selection(profile_id: str, selection: dict[str, str]) -> dict[str, str]:
+ chosen = validate_selection(selection)
+ checked = {SLOT_TO_ID_KEY[slot]: chosen[slot]["id"] for slot in USER_SLOTS}
+ with get_db() as conn:
+ conn.execute(
+ """
+ INSERT INTO journal_generation_selection (
+ profile_id, transformation_id, detail_id, voice_id, narrative_id, updated
+ )
+ VALUES (?, ?, ?, ?, ?, datetime('now'))
+ ON CONFLICT(profile_id) DO UPDATE SET
+ transformation_id = excluded.transformation_id,
+ detail_id = excluded.detail_id,
+ voice_id = excluded.voice_id,
+ narrative_id = excluded.narrative_id,
+ updated = datetime('now')
+ """,
+ (
+ profile_id,
+ checked["transformation_id"],
+ checked["detail_id"],
+ checked["voice_id"],
+ checked["narrative_id"],
+ ),
+ )
+ stored = load_selection(profile_id) or {**checked, "updated": ""}
+ return stored
+
+
+def get_or_create_selection(profile_id: str) -> dict[str, str]:
+ existing = load_selection(profile_id)
+ if existing is not None:
+ return existing
+ return save_selection(profile_id, default_selection_ids())
+
+
+def settings_payload(profile_id: str) -> dict:
+ stored = get_or_create_selection(profile_id)
+ selection = {key: stored[key] for key in SELECTION_KEYS}
+ return {
+ "selection": selection,
+ "updated": stored.get("updated") or "",
+ "options": active_user_options(),
+ "defaults": default_selection_ids(),
+ }
+
+
+def resolve_run_selection(
+ profile_id: str,
+ snapshot: dict | None,
+ remember: bool,
+) -> tuple[dict[str, str], dict]:
+ if snapshot is None:
+ stored = get_or_create_selection(profile_id)
+ values = {key: stored[key] for key in SELECTION_KEYS}
+ validate_selection(values)
+ return values, {"source": "profile", "remembered": False}
+ values = {key: str(snapshot.get(key) or "").strip() for key in SELECTION_KEYS}
+ validate_selection(values)
+ remembered = False
+ if remember:
+ save_selection(profile_id, values)
+ remembered = True
+ return values, {"source": "request", "remembered": remembered}
+
+
+def mark_guidelines_used(ids: list[str]) -> None:
+ clean = [item for item in ids if item]
+ if not clean:
+ return
+ with get_db() as conn:
+ conn.executemany(
+ """
+ UPDATE generation_guidelines
+ SET used_at = COALESCE(used_at, datetime('now'))
+ WHERE id = ?
+ """,
+ [(item,) for item in clean],
+ )
+
+
+def _write_fields(raw: dict) -> dict:
+ max_instruction, max_label, max_summary = _limits()
+ return {
+ "guideline_key": _clean_key(raw.get("guideline_key") or raw.get("key")),
+ "label": _clean_text(raw.get("label"), "label", max_chars=max_label),
+ "summary": _clean_text(raw.get("summary"), "summary", max_chars=max_summary, allow_empty=True),
+ "instruction": _clean_text(raw.get("instruction"), "instruction", max_chars=max_instruction),
+ "sort_order": int(raw.get("sort_order") or 0),
+ }
+
+
+def create_guideline(purpose: str, slot: str, body: dict) -> dict:
+ if slot not in ALLOWED_SLOTS:
+ raise CatalogError("Unbekannter Slot.", code="invalid_generation_guideline", status_code=400)
+ fields = _write_fields(body)
+ guideline_id = str(uuid.uuid4())
+ with get_db() as conn:
+ count = conn.execute(
+ "SELECT COALESCE(MAX(sort_order), -1) AS n FROM generation_guidelines WHERE purpose = ? AND slot = ?",
+ (purpose, slot),
+ ).fetchone()["n"]
+ conn.execute(
+ """
+ INSERT INTO generation_guidelines (
+ id, purpose, slot, guideline_key, label, summary, instruction, sort_order,
+ status, revision, cloned_from, is_default, is_system_seed, seed_id, seed_revision,
+ created, updated
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, NULL, 0, 0, '', '', datetime('now'), datetime('now'))
+ """,
+ (
+ guideline_id,
+ purpose,
+ slot,
+ fields["guideline_key"],
+ fields["label"],
+ fields["summary"],
+ fields["instruction"],
+ fields["sort_order"] if body.get("sort_order") is not None else int(count) + 1,
+ STATUS_DRAFT,
+ ),
+ )
+ return get_guideline(guideline_id)
+
+
+def clone_guideline(guideline_id: str) -> dict:
+ current = _fetch(guideline_id)
+ if not current:
+ raise CatalogError("Ausprägung nicht gefunden.", code="guideline_missing", status_code=404)
+ if current.get("slot") == LEGACY_SOURCE_MODE_SLOT:
+ raise CatalogError(
+ "Der Quellenmodus ist kein aktiver Slot mehr.",
+ code="invalid_generation_guideline",
+ status_code=400,
+ )
+ new_id = str(uuid.uuid4())
+ with get_db() as conn:
+ conn.execute(
+ """
+ INSERT INTO generation_guidelines (
+ id, purpose, slot, guideline_key, label, summary, instruction, sort_order,
+ status, revision, cloned_from, is_default, is_system_seed, seed_id, seed_revision,
+ created, updated
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, '', ?, datetime('now'), datetime('now'))
+ """,
+ (
+ new_id,
+ current["purpose"],
+ current["slot"],
+ current["guideline_key"],
+ current["label"],
+ current["summary"],
+ current["instruction"],
+ int(current.get("sort_order") or 0),
+ STATUS_DRAFT,
+ int(current.get("revision") or 1) + 1,
+ current["id"],
+ current.get("seed_revision") or "",
+ ),
+ )
+ return get_guideline(new_id)
+
+
+def update_guideline(guideline_id: str, body: dict) -> dict:
+ current = _fetch(guideline_id)
+ if not current:
+ raise CatalogError("Ausprägung nicht gefunden.", code="guideline_missing", status_code=404)
+ if current.get("status") != STATUS_DRAFT:
+ raise CatalogError(
+ "Aktive oder archivierte Ausprägungen sind unveränderlich. Bitte klonen.",
+ code="guideline_immutable",
+ status_code=409,
+ )
+ fields = _write_fields({**current, **body, "guideline_key": body.get("guideline_key") or current["guideline_key"]})
+ with get_db() as conn:
+ conn.execute(
+ """
+ UPDATE generation_guidelines
+ SET guideline_key = ?, label = ?, summary = ?, instruction = ?, sort_order = ?,
+ updated = datetime('now')
+ WHERE id = ?
+ """,
+ (
+ fields["guideline_key"],
+ fields["label"],
+ fields["summary"],
+ fields["instruction"],
+ fields["sort_order"],
+ guideline_id,
+ ),
+ )
+ return get_guideline(guideline_id)
+
+
+def publish_guideline(guideline_id: str) -> dict:
+ current = _fetch(guideline_id)
+ if not current:
+ raise CatalogError("Ausprägung nicht gefunden.", code="guideline_missing", status_code=404)
+ if current.get("status") == STATUS_ARCHIVED:
+ raise CatalogError("Archivierte Ausprägungen können nicht veröffentlicht werden.", code="guideline_archived", status_code=409)
+ max_instruction, max_label, max_summary = _limits()
+ _clean_text(current.get("label"), "label", max_chars=max_label)
+ _clean_text(current.get("summary"), "summary", max_chars=max_summary, allow_empty=True)
+ _clean_text(current.get("instruction"), "instruction", max_chars=max_instruction)
+ with get_db() as conn:
+ conn.execute(
+ "UPDATE generation_guidelines SET status = ?, updated = datetime('now') WHERE id = ?",
+ (STATUS_ACTIVE, guideline_id),
+ )
+ return get_guideline(guideline_id, include_instruction=False)
+
+
+def archive_guideline(guideline_id: str) -> dict:
+ current = _fetch(guideline_id)
+ if not current:
+ raise CatalogError("Ausprägung nicht gefunden.", code="guideline_missing", status_code=404)
+ if current.get("status") == STATUS_DRAFT:
+ raise CatalogError("Drafts werden gelöscht, nicht archiviert.", code="invalid_generation_guideline", status_code=400)
+ with get_db() as conn:
+ remaining = conn.execute(
+ """
+ SELECT COUNT(*) AS n FROM generation_guidelines
+ WHERE purpose = ? AND slot = ? AND status = ? AND id != ?
+ """,
+ (current["purpose"], current["slot"], STATUS_ACTIVE, guideline_id),
+ ).fetchone()["n"]
+ if int(remaining or 0) < 1:
+ raise CatalogError(
+ "Die letzte aktive Ausprägung dieser Dimension kann nicht archiviert werden.",
+ code="guideline_last_active",
+ status_code=409,
+ )
+ with get_db() as conn:
+ conn.execute(
+ """
+ UPDATE generation_guidelines
+ SET status = ?, is_default = 0, updated = datetime('now')
+ WHERE id = ?
+ """,
+ (STATUS_ARCHIVED, guideline_id),
+ )
+ return get_guideline(guideline_id, include_instruction=False)
+
+
+def set_default_guideline(guideline_id: str) -> dict:
+ current = _fetch(guideline_id)
+ if not current:
+ raise CatalogError("Ausprägung nicht gefunden.", code="guideline_missing", status_code=404)
+ if current.get("status") != STATUS_ACTIVE:
+ raise CatalogError("Nur aktive Ausprägungen können Standard sein.", code="invalid_generation_guideline", status_code=400)
+ if current["slot"] not in USER_SLOTS:
+ raise CatalogError("Nur die vier Gestaltungsdimensionen haben einen Nutzerstandard.", code="invalid_generation_guideline", status_code=400)
+ with get_db() as conn:
+ conn.execute(
+ """
+ UPDATE generation_guidelines
+ SET is_default = CASE WHEN id = ? THEN 1 ELSE 0 END, updated = datetime('now')
+ WHERE purpose = ? AND slot = ?
+ """,
+ (guideline_id, current["purpose"], current["slot"]),
+ )
+ return get_guideline(guideline_id, include_instruction=False)
+
+
+def delete_guideline(guideline_id: str) -> None:
+ current = _fetch(guideline_id)
+ if not current:
+ raise CatalogError("Ausprägung nicht gefunden.", code="guideline_missing", status_code=404)
+ if current.get("status") != STATUS_DRAFT:
+ raise CatalogError("Nur unverwendete Drafts können gelöscht werden.", code="guideline_immutable", status_code=409)
+ if current.get("used_at"):
+ raise CatalogError("Verwendete Drafts können nicht gelöscht werden.", code="guideline_in_use", status_code=409)
+ with get_db() as conn:
+ conn.execute("DELETE FROM generation_guidelines WHERE id = ?", (guideline_id,))
+
+
+def reset_seed_drafts(purpose: str = PURPOSE_JOURNAL) -> dict:
+ """Create new drafts from the current seed. Never overwrite published variants."""
+ seed = load_seed_document()
+ created = []
+ slots = seed.get("slots") or {}
+ for slot in ALLOWED_SLOTS:
+ for index, item in enumerate((slots.get(slot) or {}).get("variants") or []):
+ created.append(
+ create_guideline(
+ purpose,
+ slot,
+ {
+ "guideline_key": item.get("guideline_key") or item.get("variant_key"),
+ "label": item.get("label") or "",
+ "summary": item.get("summary") or "",
+ "instruction": item.get("instruction") or "",
+ "sort_order": index,
+ },
+ )
+ )
+ overview = overview_payload(purpose)
+ overview["reset_drafts"] = created
+ return overview
+
+
+def preview_selection(selection: dict[str, str], *, purpose: str = PURPOSE_JOURNAL) -> dict:
+ from engine import load_active_prompt, preview_prompt
+
+ compiled = compile_selection(selection, purpose=purpose)
+ prompt = load_active_prompt("mvp.journal_generate")
+ assert_journal_prompt_contract(prompt.get("template") or "")
+ rendered = preview_prompt(
+ prompt,
+ {
+ **compiled.instructions,
+ "writing_profile": "(nicht enthalten)",
+ "style_examples": "(nicht enthalten)",
+ "reconstruction": "(nicht enthalten)",
+ "existing_text": "",
+ },
+ )
+ assert_template_resolved(rendered.get("rendered") or "")
+ return {
+ **compiled.instructions,
+ "rendered": rendered.get("rendered") or "",
+ "prompt_slug": prompt.get("slug") or "",
+ "prompt_revision": prompt.get("seed_revision") or "",
+ "selection": {
+ "ids": dict(compiled.ids),
+ "keys": dict(compiled.keys),
+ "labels": dict(compiled.labels),
+ "revisions": dict(compiled.revisions),
+ },
+ "seed_revision": compiled.seed_revision,
+ }
+
+
+def _seed_rows(seed: dict, purpose: str) -> list[tuple]:
+ revision = seed.get("seed_revision") or ""
+ rows = []
+ slots = seed.get("slots") or {}
+ for slot in ALLOWED_SLOTS:
+ variants = (slots.get(slot) or {}).get("variants") or []
+ for index, item in enumerate(variants):
+ rows.append(
+ (
+ item.get("id") or str(uuid.uuid4()),
+ purpose,
+ slot,
+ item.get("guideline_key") or item.get("variant_key"),
+ item.get("label") or "",
+ item.get("summary") or "",
+ item.get("instruction") or "",
+ index,
+ STATUS_ACTIVE,
+ 1,
+ 1 if item.get("is_default") else 0,
+ 1,
+ item.get("id") or "",
+ revision,
+ )
+ )
+ return rows
+
+
+def seed_generation_instructions(conn) -> None:
+ """Insert missing seed guidelines. Never overwrite published or admin-created rows."""
+ seed = load_seed_document()
+ purpose = seed.get("purpose") or PURPOSE_JOURNAL
+ conn.execute(
+ """
+ UPDATE generation_guidelines
+ SET status = ?, is_default = 0, updated = datetime('now')
+ WHERE purpose = ? AND slot = ? AND status != ?
+ """,
+ (STATUS_ARCHIVED, purpose, LEGACY_SOURCE_MODE_SLOT, STATUS_ARCHIVED),
+ )
+ existing = {
+ row["id"]
+ for row in conn.execute("SELECT id FROM generation_guidelines WHERE purpose = ?", (purpose,)).fetchall()
+ }
+ existing_seed_ids = {
+ row["seed_id"]
+ for row in conn.execute(
+ "SELECT seed_id FROM generation_guidelines WHERE purpose = ? AND seed_id != ''",
+ (purpose,),
+ ).fetchall()
+ if row["seed_id"]
+ }
+ for row in _seed_rows(seed, purpose):
+ row_id = row[0]
+ seed_id = row[12]
+ if row_id in existing or seed_id in existing_seed_ids:
+ continue
+ conn.execute(
+ """
+ INSERT INTO generation_guidelines (
+ id, purpose, slot, guideline_key, label, summary, instruction, sort_order,
+ status, revision, cloned_from, is_default, is_system_seed, seed_id, seed_revision,
+ created, updated
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, datetime('now'), datetime('now'))
+ """,
+ row,
+ )
+
+
+def backfill_missing_settings(conn) -> None:
+ seed_generation_instructions(conn)
+ defaults = {}
+ for slot, key in SLOT_TO_ID_KEY.items():
+ row = conn.execute(
+ """
+ SELECT id FROM generation_guidelines
+ WHERE purpose = ? AND slot = ? AND status = ? AND is_default = 1
+ ORDER BY sort_order
+ LIMIT 1
+ """,
+ (PURPOSE_JOURNAL, slot, STATUS_ACTIVE),
+ ).fetchone()
+ if not row:
+ row = conn.execute(
+ """
+ SELECT id FROM generation_guidelines
+ WHERE purpose = ? AND slot = ? AND status = ?
+ ORDER BY sort_order
+ LIMIT 1
+ """,
+ (PURPOSE_JOURNAL, slot, STATUS_ACTIVE),
+ ).fetchone()
+ if row:
+ defaults[key] = row["id"]
+ if len(defaults) != 4:
+ return
+ conn.execute(
+ """
+ INSERT OR IGNORE INTO journal_generation_selection (
+ profile_id, transformation_id, detail_id, voice_id, narrative_id, updated
+ )
+ SELECT id, ?, ?, ?, ?, datetime('now') FROM profiles
+ """,
+ (
+ defaults["transformation_id"],
+ defaults["detail_id"],
+ defaults["voice_id"],
+ defaults["narrative_id"],
+ ),
+ )
diff --git a/backend/journal_store.py b/backend/journal_store.py
index e816b88..cdf3676 100644
--- a/backend/journal_store.py
+++ b/backend/journal_store.py
@@ -89,7 +89,19 @@ def _owned(conn, table: str, record_id: str, profile_id: str) -> dict | None:
def _decode_draft(conn, row: dict | None) -> dict | None:
if not row:
return None
- return _attach_sources(conn, "journal_draft_source_refs", "draft_id", row["id"], row)
+ payload = _attach_sources(conn, "journal_draft_source_refs", "draft_id", row["id"], row)
+ raw = payload.get("generation_snapshot") or "{}"
+ try:
+ snapshot = json.loads(raw) if isinstance(raw, str) else (raw or {})
+ except json.JSONDecodeError:
+ snapshot = {}
+ if not isinstance(snapshot, dict):
+ snapshot = {}
+ payload["generation_snapshot"] = snapshot
+ from journal_generation_policy import snapshot_summary
+
+ payload["generation_summary"] = snapshot_summary(snapshot)
+ return payload
def _decode_version(conn, row: dict | None) -> dict | None:
@@ -219,6 +231,7 @@ def insert_draft(
body: str,
source_conversation_ids: list[str],
source_message_ids: list[str],
+ generation_snapshot: dict | None = None,
) -> dict:
get_day(profile_id, journal_day_id)
draft_id = str(uuid.uuid4())
@@ -236,8 +249,8 @@ def insert_draft(
"""
INSERT INTO journal_drafts
(id, profile_id, journal_day_id, title, body,
- source_conversation_ids, source_message_ids, as_of)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ source_conversation_ids, source_message_ids, as_of, generation_snapshot)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
draft_id,
@@ -248,6 +261,7 @@ def insert_draft(
"[]",
"[]",
as_of,
+ json.dumps(generation_snapshot or {}, ensure_ascii=False),
),
)
_insert_source_refs(
diff --git a/backend/main.py b/backend/main.py
index 6d4dd59..21fca30 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -7,7 +7,7 @@ from fastapi.middleware.cors import CORSMiddleware
from db import init_db
import data_layer_dialogue # noqa: F401
-from routers import admin, auth, dialogue, journal, placeholders, prompts, subscription, users
+from routers import admin, auth, dialogue, generation_instructions, journal, placeholders, prompts, subscription, users
from version import APP_VERSION
app = FastAPI(title="Kanshō", version=APP_VERSION)
@@ -28,6 +28,7 @@ app.include_router(prompts.router)
app.include_router(placeholders.router)
app.include_router(subscription.router)
app.include_router(admin.router)
+app.include_router(generation_instructions.router)
@app.on_event("startup")
diff --git a/backend/placeholder_mvp.py b/backend/placeholder_mvp.py
index d86c475..961cd76 100644
--- a/backend/placeholder_mvp.py
+++ b/backend/placeholder_mvp.py
@@ -34,18 +34,34 @@ register(
)
register(
Placeholder(
- key="editorial_mode",
- description="Lokaler redaktioneller Journalmodus. Kein zweiter Modellaufruf.",
+ key="transformation_instructions",
+ description="Kompilierte Bearbeitungsstärke. Semantische Ausgabeeinstellung, keine Modelltemperatur.",
data_class="C",
- resolver=_from_ctx("editorial_mode"),
+ resolver=_from_ctx("transformation_instructions"),
)
)
register(
Placeholder(
- key="editorial_instructions",
- description="Modusabhängige Journalinstruktion. Kein allgemeiner Provenienzvertrag.",
+ key="detail_instructions",
+ description="Kompilierte Detailerhaltung. Semantische Ausgabeeinstellung, keine Modelltemperatur.",
data_class="C",
- resolver=_from_ctx("editorial_instructions"),
+ resolver=_from_ctx("detail_instructions"),
+ )
+)
+register(
+ Placeholder(
+ key="voice_instructions",
+ description="Kompilierte Stimmanweisung aus dem Generation-Policy-Compiler.",
+ data_class="C",
+ resolver=_from_ctx("voice_instructions"),
+ )
+)
+register(
+ Placeholder(
+ key="narrative_instructions",
+ description="Kompilierte Erzählgestaltung aus dem Generation-Policy-Compiler.",
+ data_class="C",
+ resolver=_from_ctx("narrative_instructions"),
)
)
register(
diff --git a/backend/privacy_gateway.py b/backend/privacy_gateway.py
index e429d48..11ea461 100644
--- a/backend/privacy_gateway.py
+++ b/backend/privacy_gateway.py
@@ -1,7 +1,7 @@
"""Local privacy gateway. Personal generative egress is not allowed to skip this layer."""
from __future__ import annotations
-from dataclasses import dataclass, field
+from dataclasses import dataclass, field, replace
from datetime import datetime, timezone
from typing import Any
import contextvars
@@ -9,7 +9,7 @@ import re
import time
from entity_detect import DetectError, detect_personal_egress, reset_detect_test_hooks, user_detect_message
-from identity_store import KINSHIP, is_maskable_label
+from identity_store import KINSHIP, is_maskable_label, mapping_spellings
from journal_reconstruct import claim_texts, fake_reconstruction, is_dialogue_role_line
from prompt_budget import (
ERROR_PROVIDER_CONTEXT_LENGTH,
@@ -36,6 +36,8 @@ COMPACT_DIAGNOSTIC_KEYS = (
"provider",
"purpose",
"prompt_slug",
+ "prompt_revision",
+ "generate_ms",
"effective_context_window",
"estimated_input_tokens",
"prompt_tokens",
@@ -59,6 +61,11 @@ COMPACT_DIAGNOSTIC_KEYS = (
"pre_egress_validation",
"response_validation",
"response_validation_retry",
+ "response_normalization",
+ "normalized_cleartext_count",
+ "generate_calls",
+ "model_text_accepted",
+ "provenance_decision",
"full_detection_coverage",
"detect_provider",
"detect_model",
@@ -124,10 +131,18 @@ class ActiveReplacement:
occurrence_count: int
local_label: str
demask_label: str = ""
+ source: str = ""
+ span_based: bool = False
+ labels: tuple[str, ...] = ()
def restore_label(self) -> str:
return self.demask_label or self.local_label
+ def all_labels(self) -> tuple[str, ...]:
+ if self.labels:
+ return self.labels
+ return (self.local_label,) if self.local_label else ()
+
@dataclass
class MaskingManifest:
@@ -380,6 +395,53 @@ def _active_replacements(text: str, mappings: list[dict]) -> tuple[ActiveReplace
return tuple(found)
+_SPAN_TYPE_PRIORITY = {"PERSON": 0, "PROJECT": 1, "ORG": 2, "PLACE": 3}
+
+
+def _placeholder_of(token: str) -> str:
+ raw = (token or "").strip()
+ if raw.startswith("[[") and raw.endswith("]]"):
+ return f"[[{_canonical_token(raw)}]]"
+ return f"[[{_canonical_token(raw)}]]"
+
+
+def _ground_mapping_span(text: str, item: dict) -> tuple[int, int] | None:
+ label = (item.get("local_label") or "").strip()
+ if not label or "start" not in item or "end" not in item:
+ return None
+ try:
+ start = int(item.get("start"))
+ end = int(item.get("end"))
+ except (TypeError, ValueError):
+ return None
+ if start < 0 or end > len(text or "") or start >= end:
+ return None
+ slice_text = (text or "")[start:end]
+ if slice_text == label or slice_text.casefold() == label.casefold():
+ return start, end
+ return None
+
+
+def _resolve_span_mappings(items: list[dict]) -> list[dict]:
+ ordered = sorted(
+ items,
+ key=lambda row: (
+ -(int(row["end"]) - int(row["start"])),
+ int(row["start"]),
+ _SPAN_TYPE_PRIORITY.get((row.get("entity_type") or "").upper(), 9),
+ ),
+ )
+ kept: list[dict] = []
+ occupied: list[tuple[int, int]] = []
+ for item in ordered:
+ start, end = int(item["start"]), int(item["end"])
+ if any(start < right and end > left for left, right in occupied):
+ continue
+ kept.append(item)
+ occupied.append((start, end))
+ return sorted(kept, key=lambda row: int(row["start"]))
+
+
def _mask_body(text: str, mappings: list[dict]) -> str:
masked = text
for item in sorted(mappings, key=lambda row: len(row.get("local_label") or ""), reverse=True):
@@ -387,7 +449,7 @@ def _mask_body(text: str, mappings: list[dict]) -> str:
token = (item.get("token") or "").strip()
if not label or not token or not is_maskable_label(label):
continue
- placeholder = token if token.startswith("[[") else f"[[{token}]]"
+ placeholder = _placeholder_of(token)
pattern = _label_pattern(label)
def repl(match: re.Match, *, _token=token, _ph=placeholder) -> str:
@@ -413,12 +475,6 @@ def _mask(text: str, mappings: list[dict], *, personal_lines_only: bool = False)
return "".join(parts)
-IDENTITY_LEAK_RETRY = (
- "Korrektur: Keine Klartext-Identität. Kopiere die im Auftragstext bereits "
- "vorhandenen Platzhalter zeichengetreu. Keine Klarnamen, keine Klarorte, "
- "keine neuen Platzhalter, keine Auslassungspunkte."
-)
-
PLACEHOLDER_RE = re.compile(r"\[\[\s*([^\[\]]+?)\s*\]\]")
GENERIC_PLACEHOLDER_INNER = frozenset({"…", "...", "..", "...."})
@@ -468,15 +524,74 @@ def _restore_map_from_mappings(mappings: list[dict] | None) -> dict[str, str]:
def mask_prompt(rendered: str, mappings: list[dict], purpose: str) -> MaskingManifest:
- """Request-scoped mask result. Active mappings are those actually replaced."""
- masked = rendered or ""
- replacements: list[ActiveReplacement] = []
- for item in sorted(mappings or [], key=lambda row: len(row.get("local_label") or ""), reverse=True):
+ """Request-scoped mask result. Detected spans replace only those offsets.
+
+ Request-local detections never globally replace a label. Confirmed-registry
+ rows without spans remain a separate identity-mention safety net.
+ """
+ text = rendered or ""
+ replacements: dict[str, ActiveReplacement] = {}
+
+ def _merge_labels(existing: tuple[str, ...], item: dict) -> tuple[str, ...]:
+ labels = list(existing)
+ seen = {label.casefold() for label in labels}
+ for label in mapping_spellings(item):
+ key = label.casefold()
+ if key in seen:
+ continue
+ seen.add(key)
+ labels.append(label)
+ return tuple(labels)
+
+ def remember(item: dict, count: int, *, span_based: bool) -> None:
+ if count <= 0:
+ return
+ token = _canonical_token(item.get("token") or "")
+ if not token:
+ return
+ existing = replacements.get(token)
+ replacements[token] = ActiveReplacement(
+ token=token,
+ entity_type=_entity_type_of(item),
+ occurrence_count=(existing.occurrence_count if existing else 0) + count,
+ local_label=(item.get("local_label") or (existing.local_label if existing else "")).strip(),
+ demask_label=(
+ item.get("demask_label")
+ or item.get("canonical_label")
+ or (existing.demask_label if existing else "")
+ or item.get("local_label")
+ or ""
+ ),
+ source=(item.get("source") or (existing.source if existing else "")),
+ span_based=bool((existing.span_based if existing else False) or span_based),
+ labels=_merge_labels(existing.labels if existing else (), item),
+ )
+
+ span_rows: list[dict] = []
+ label_rows: list[dict] = []
+ for item in mappings or []:
label = (item.get("local_label") or "").strip()
token = (item.get("token") or "").strip()
if not label or not token or not is_maskable_label(label):
continue
- placeholder = token if token.startswith("[[") else f"[[{_canonical_token(token)}]]"
+ has_span_fields = item.get("start") is not None and item.get("end") is not None
+ grounded = _ground_mapping_span(text, item) if has_span_fields else None
+ if grounded is not None:
+ span_rows.append({**item, "start": grounded[0], "end": grounded[1]})
+ elif not has_span_fields:
+ label_rows.append(item)
+
+ applied = _resolve_span_mappings(span_rows)
+ masked = text
+ for item in sorted(applied, key=lambda row: int(row["start"]), reverse=True):
+ start, end = int(item["start"]), int(item["end"])
+ masked = masked[:start] + _placeholder_of(item.get("token") or "") + masked[end:]
+ remember(item, 1, span_based=True)
+
+ for item in sorted(label_rows, key=lambda row: len(row.get("local_label") or ""), reverse=True):
+ label = (item.get("local_label") or "").strip()
+ token = (item.get("token") or "").strip()
+ placeholder = _placeholder_of(token)
pattern = _label_pattern(label)
current = masked
count = 0
@@ -489,23 +604,22 @@ def mask_prompt(rendered: str, mappings: list[dict], purpose: str) -> MaskingMan
return _ph
masked = pattern.sub(repl, current)
- if count <= 0:
- continue
- replacements.append(
- ActiveReplacement(
- token=_canonical_token(token),
- entity_type=_entity_type_of(item),
- occurrence_count=count,
- local_label=label,
- demask_label=(item.get("demask_label") or item.get("canonical_label") or label),
- )
- )
+ remember(item, count, span_based=False)
+
if purpose == "dialogue_turn":
masked = bind_user_lines(masked)
+ for token, item in list(replacements.items()):
+ labels = item.labels
+ for row in mappings or []:
+ if _canonical_token(row.get("token") or "") != token:
+ continue
+ labels = _merge_labels(labels, row)
+ if labels != item.labels:
+ replacements[token] = replace(item, labels=labels)
return MaskingManifest(
masked_text=masked,
available_mapping_count=len(mappings or []),
- replacements=tuple(replacements),
+ replacements=tuple(replacements.values()),
restore_by_token=_restore_map_from_mappings(mappings),
)
@@ -516,14 +630,30 @@ def mask_for_egress(rendered: str, mappings: list[dict], purpose: str) -> str:
def validate_pre_egress(masked_text: str, manifest: MaskingManifest) -> None:
- """Fail closed before the provider if an active identity remains as plaintext."""
+ """Fail closed before the provider if a confirmed identity remains as plaintext.
+
+ Request-local span replacements may leave the same wording unmasked where it
+ was not detected as identity. That remaining wording is not an egress leak.
+ """
leaked_tokens: list[str] = []
leaked_types: list[str] = []
+ body = masked_text or ""
for item in manifest.replacements:
- for match in _label_pattern(item.local_label).finditer(masked_text or ""):
- if _is_identity_mention(masked_text, match.start(), match.end(), item.token):
+ if item.span_based and item.source != "confirmed_registry":
+ placeholder = f"[[{canonical_token(item.token)}]]"
+ if placeholder not in body:
leaked_tokens.append(item.token)
leaked_types.append(item.entity_type)
+ continue
+ leaked = False
+ for label in item.all_labels():
+ for match in _label_pattern(label).finditer(body):
+ if _is_identity_mention(body, match.start(), match.end(), item.token):
+ leaked_tokens.append(item.token)
+ leaked_types.append(item.entity_type)
+ leaked = True
+ break
+ if leaked:
break
if leaked_tokens:
raise PrivacyGatewayError(
@@ -553,39 +683,63 @@ def _demask(text: str, manifest: MaskingManifest | None) -> str:
return PLACEHOLDER_RE.sub(repl, result)
-def _validate_response(content: str, manifest: MaskingManifest | None = None) -> str:
+def _normalize_active_cleartext(text: str, manifest: MaskingManifest | None) -> tuple[str, int]:
+ """Map active identity cleartext back to the request token. Local only, no retry."""
+ if not manifest or not manifest.replacements:
+ return text or "", 0
+ hits: list[tuple[int, int, str]] = []
+ occupied: list[tuple[int, int]] = []
+ labeled: list[tuple[ActiveReplacement, str]] = []
+ for item in manifest.replacements:
+ for label in item.all_labels():
+ if label:
+ labeled.append((item, label))
+ items = sorted(labeled, key=lambda row: len(row[1]), reverse=True)
+ for item, label in items:
+ placeholder = _placeholder_of(item.token)
+ for match in _label_pattern(label).finditer(text or ""):
+ if not _is_identity_mention(text, match.start(), match.end(), item.token):
+ continue
+ if any(match.start() < right and match.end() > left for left, right in occupied):
+ continue
+ occupied.append((match.start(), match.end()))
+ hits.append((match.start(), match.end(), placeholder))
+ result = text or ""
+ for start, end, placeholder in sorted(hits, key=lambda row: row[0], reverse=True):
+ result = result[:start] + placeholder + result[end:]
+ return result, len(hits)
+
+
+def _process_response(content: str, manifest: MaskingManifest | None = None) -> tuple[str, dict[str, Any]]:
+ """Integrity check after the model reply. Does not undo egress and does not retry."""
text = (content or "").strip()
if not text:
raise PrivacyGatewayError("empty_provider_response", "Der Provider lieferte keine Antwort.")
- leaked_tokens: list[str] = []
- leaked_types: list[str] = []
- for item in manifest.replacements if manifest else ():
- for match in _label_pattern(item.local_label).finditer(text):
- if _is_identity_mention(text, match.start(), match.end(), item.token):
- leaked_tokens.append(item.token)
- leaked_types.append(item.entity_type)
- break
- if leaked_tokens:
- raise PrivacyGatewayError(
- "response_validation_failed",
- "Antwort enthielt Klartext-Identität vor der Demaskierung.",
- diagnostics={
- "response_validation": "failed",
- "leak_tokens": leaked_tokens,
- "leak_entity_types": leaked_types,
- },
- )
+ normalized, count = _normalize_active_cleartext(text, manifest)
+ meta = {
+ "response_validation": "ok",
+ "response_normalization": "active_cleartext_normalized" if count else "none",
+ "normalized_cleartext_count": count,
+ }
+ return normalized, meta
+
+
+def _validate_response(content: str, manifest: MaskingManifest | None = None) -> str:
+ """Compatibility wrapper: normalize active cleartext, then return tokenized text."""
+ text, _meta = _process_response(content, manifest)
return text
def _fake_journal_from_sources(rendered: str) -> str:
"""Deterministic fake body from labeled CURRENT_DAY_SOURCES. Not a quality claim."""
text = rendered or ""
- marker = "CURRENT_DAY_SOURCES"
+ marker = "\nCURRENT_DAY_SOURCES\n"
if marker not in text:
- return ""
+ marker = "CURRENT_DAY_SOURCES"
+ if marker not in text:
+ return ""
after = text.split(marker, 1)[1]
- for stop in ("WRITING_PROFILE", "STYLE_EXAMPLES", "EXISTING_TEXT", "EDITORIAL_MODE"):
+ for stop in ("WRITING_PROFILE", "STYLE_EXAMPLES", "EXISTING_TEXT", "EDITORIAL_MODE", "AUSGABE", "DATENSCHUTZ"):
if f"\n{stop}" in after:
after = after.split(f"\n{stop}", 1)[0]
parts: list[str] = []
@@ -602,6 +756,20 @@ def _fake_journal_from_sources(rendered: str) -> str:
if current:
parts.append(" ".join(current).strip())
return " ".join(part for part in parts if part).strip()
+ parts: list[str] = []
+ current: list[str] = []
+ for line in after.splitlines():
+ stripped = line.strip()
+ if stripped.startswith("[u") and stripped.endswith("]"):
+ if current:
+ parts.append(" ".join(current).strip())
+ current = []
+ continue
+ if stripped and not stripped.startswith("(") and "einzige Tatsachen" not in stripped:
+ current.append(stripped)
+ if current:
+ parts.append(" ".join(current).strip())
+ return " ".join(part for part in parts if part).strip()
def _fake_complete(purpose: str, rendered: str) -> str:
@@ -793,12 +961,14 @@ def complete(request: GatewayRequest) -> GatewayResult:
diagnostics["provider"] = result.provider
diagnostics["purpose"] = request.purpose
diagnostics["prompt_slug"] = (request.payload or {}).get("prompt_slug")
+ diagnostics["prompt_revision"] = (request.payload or {}).get("prompt_revision")
diagnostics["model"] = config.model if config else None
request_trace = {
"purpose": request.purpose,
"layer": layer,
"data_class": request.data_class,
"prompt_slug": (request.payload or {}).get("prompt_slug"),
+ "prompt_revision": (request.payload or {}).get("prompt_revision"),
"rendered": rendered,
"masked": masked,
"mask_input": rendered,
@@ -860,14 +1030,26 @@ def complete(request: GatewayRequest) -> GatewayResult:
leak_entity_types=leak.get("leak_entity_types"),
)
failed = merge_usage(
- {**diagnostics, "budget_ok": False, "abort_reason": exc.code, "status": "error"},
+ {**diagnostics, "budget_ok": False, "abort_reason": exc.code, "status": "error", "generate_calls": 0},
None,
config.model if config else None,
)
+ request_trace["abort_reason"] = exc.code
+ request_trace["generate_calls"] = 0
+ request_trace["model_text_accepted"] = False
+ request_trace["budget"] = compact_diagnostics(failed)
last_compact = compact_diagnostics(failed)
_record_test({"purpose": request.purpose, "ok": False, "code": exc.code})
- _raise_with_log(exc, events, purpose=request.purpose)
+ _raise_with_log(
+ exc,
+ events,
+ purpose=request.purpose,
+ extra={**failed, "trace": public_trace(request_trace)},
+ )
usages: list[dict] = []
+ raw = ""
+ validated = ""
+ chat = None
try:
_log_event(
events,
@@ -880,75 +1062,53 @@ def complete(request: GatewayRequest) -> GatewayResult:
chat = complete_model([{"role": "user", "content": masked}], model_policy)
usages.append(chat.usage or {})
diagnostics["generate_called"] = True
+ diagnostics["generate_calls"] = 1
request_trace["generate_called"] = True
+ request_trace["generate_calls"] = 1
_log_event(events, started, "model_result", attempt=1, **_usage_bits(chat.usage))
raw = chat.content
- try:
- validated = _validate_response(raw, manifest)
- diagnostics["response_validation"] = "ok"
- _log_event(events, started, "response_validation", attempt=1, status="ok")
- except PrivacyGatewayError as exc:
- if exc.code != "response_validation_failed":
- raise
- leak = dict(exc.diagnostics or {})
- diagnostics["response_validation"] = "failed"
- _log_event(
- events,
- started,
- "response_validation",
- attempt=1,
- status="failed",
- code=exc.code,
- leak_tokens=leak.get("leak_tokens"),
- leak_entity_types=leak.get("leak_entity_types"),
- )
- _log_event(events, started, "retry", attempt=2, reason="response_validation_failed")
- diagnostics["response_validation_retry"] = 1
- request_trace["response_validation_retry"] = 1
- _log_event(
- events,
- started,
- "model_call",
- attempt=2,
- purpose=request.purpose,
- model=config.model if config else None,
- )
- chat = complete_model(
- [{"role": "user", "content": masked + "\n\n" + IDENTITY_LEAK_RETRY}],
- model_policy,
- )
- usages.append(chat.usage or {})
- _log_event(events, started, "model_result", attempt=2, **_usage_bits(chat.usage))
- raw = chat.content
- validated = _validate_response(raw, manifest)
- diagnostics["response_validation"] = "ok"
- _log_event(events, started, "response_validation", attempt=2, status="ok")
- except PrivacyGatewayError as exc:
- if exc.code == "response_validation_failed":
- leak = dict(exc.diagnostics or {})
- diagnostics["response_validation"] = "failed"
- _log_event(
- events,
- started,
- "response_validation",
- attempt=2 if diagnostics.get("response_validation_retry") else 1,
- status="failed",
- code=exc.code,
- leak_tokens=leak.get("leak_tokens"),
- leak_entity_types=leak.get("leak_entity_types"),
- )
- failed = merge_usage(
- {**diagnostics, "budget_ok": False, "abort_reason": exc.code, "status": "error"},
- sum_usages(usages) or None,
- config.model if config else None,
+ validated, process_meta = _process_response(raw, manifest)
+ diagnostics["response_validation"] = process_meta["response_validation"]
+ diagnostics["response_normalization"] = process_meta["response_normalization"]
+ diagnostics["normalized_cleartext_count"] = process_meta["normalized_cleartext_count"]
+ request_trace["response_validation"] = process_meta["response_validation"]
+ request_trace["response_normalization"] = process_meta["response_normalization"]
+ request_trace["normalized_cleartext_count"] = process_meta["normalized_cleartext_count"]
+ diagnostics["model_text_accepted"] = True
+ _log_event(
+ events,
+ started,
+ "response_validation",
+ attempt=1,
+ status="ok",
+ response_normalization=process_meta["response_normalization"],
)
+ except PrivacyGatewayError as exc:
+ failed = merge_usage(
+ {
+ **diagnostics,
+ "budget_ok": False,
+ "abort_reason": exc.code,
+ "status": "error",
+ "generate_calls": len(usages),
+ "generate_ms": int((time.perf_counter() - started) * 1000),
+ },
+ sum_usages(usages) or None,
+ (chat.model if chat else None) or (config.model if config else None),
+ )
+ request_trace["raw"] = raw
+ request_trace["abort_reason"] = exc.code
+ request_trace["generate_calls"] = len(usages)
+ request_trace["model_text_accepted"] = False
+ request_trace["budget"] = compact_diagnostics(failed)
+ request_trace["log"] = events
last_compact = compact_diagnostics(failed)
_record_test({"purpose": request.purpose, "ok": False, "code": exc.code})
_raise_with_log(
exc,
events,
purpose=request.purpose,
- extra={"response_validation_retry": diagnostics.get("response_validation_retry")},
+ extra={**failed, "trace": public_trace(request_trace)},
)
diagnostics = merge_usage(
{
@@ -957,6 +1117,9 @@ def complete(request: GatewayRequest) -> GatewayResult:
"budget_ok": True,
"status": "ok",
"response_validation": "ok",
+ "generate_ms": int((time.perf_counter() - started) * 1000),
+ "generate_calls": 1,
+ "model_text_accepted": True,
},
sum_usages(usages) or chat.usage,
chat.model or (config.model if config else None),
@@ -965,10 +1128,11 @@ def complete(request: GatewayRequest) -> GatewayResult:
result.allowed = True
result.local_identities = local_identities
result.diagnostics = compact_diagnostics(diagnostics)
- request_trace["raw"] = validated
+ request_trace["raw"] = raw
request_trace["reply"] = result.content
request_trace["model"] = chat.model or request_trace.get("model")
request_trace["response_validation"] = "ok"
+ request_trace["model_text_accepted"] = True
request_trace["budget"] = compact_diagnostics(diagnostics)
request_trace["log"] = events
result.trace = public_trace(request_trace)
@@ -994,6 +1158,7 @@ def public_trace(trace: dict | None) -> dict | None:
"layer": trace.get("layer"),
"data_class": trace.get("data_class"),
"prompt_slug": trace.get("prompt_slug"),
+ "prompt_revision": trace.get("prompt_revision"),
"provider": trace.get("provider"),
"model": trace.get("model"),
"detect_provider": trace.get("detect_provider"),
@@ -1007,6 +1172,7 @@ def public_trace(trace: dict | None) -> dict | None:
"request_local_hits": trace.get("request_local_hits"),
"detect_calls": trace.get("detect_calls"),
"detect_ms": trace.get("detect_ms"),
+ "generate_ms": (trace.get("budget") or {}).get("generate_ms") if isinstance(trace.get("budget"), dict) else trace.get("generate_ms"),
"generate_called": trace.get("generate_called"),
"mapping_count": trace.get("mapping_count"),
"available_mapping_count": trace.get("available_mapping_count"),
@@ -1016,6 +1182,12 @@ def public_trace(trace: dict | None) -> dict | None:
"active_entity_types": trace.get("active_entity_types"),
"pre_egress_validation": trace.get("pre_egress_validation"),
"response_validation": trace.get("response_validation"),
+ "response_normalization": trace.get("response_normalization"),
+ "normalized_cleartext_count": trace.get("normalized_cleartext_count"),
+ "generate_calls": trace.get("generate_calls"),
+ "model_text_accepted": trace.get("model_text_accepted"),
+ "provenance_decision": trace.get("provenance_decision"),
+ "abort_reason": trace.get("abort_reason"),
"leak_tokens": trace.get("leak_tokens"),
"leak_entity_types": trace.get("leak_entity_types"),
"intern": trace.get("rendered") or trace.get("intern"),
diff --git a/backend/routers/generation_instructions.py b/backend/routers/generation_instructions.py
new file mode 100644
index 0000000..10e92ea
--- /dev/null
+++ b/backend/routers/generation_instructions.py
@@ -0,0 +1,151 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel
+
+from auth import require_admin_dep
+from journal_generation_policy import (
+ PURPOSE_JOURNAL,
+ CatalogError,
+ GenerationPolicyError,
+ archive_guideline,
+ clone_guideline,
+ create_guideline,
+ default_selection_ids,
+ delete_guideline,
+ get_guideline,
+ overview_payload,
+ preview_selection,
+ publish_guideline,
+ reset_seed_drafts,
+ set_default_guideline,
+ update_guideline,
+)
+
+router = APIRouter(prefix="/api/admin/generation-instructions", tags=["admin"])
+
+
+class GuidelineWrite(BaseModel):
+ slot: str | None = None
+ guideline_key: str | None = None
+ label: str | None = None
+ summary: str | None = None
+ instruction: str | None = None
+ sort_order: int | None = None
+
+
+class PreviewRequest(BaseModel):
+ generation_selection: dict | None = None
+
+
+def _http(exc: GenerationPolicyError):
+ raise HTTPException(
+ status_code=exc.status_code,
+ detail={"code": exc.code, "message": exc.message},
+ ) from exc
+
+
+def _purpose(purpose: str) -> str:
+ if purpose != PURPOSE_JOURNAL:
+ raise HTTPException(404, "Unbekannter Generation-Purpose")
+ return purpose
+
+
+@router.get("/{purpose}")
+def list_overview(purpose: str, session: dict = Depends(require_admin_dep)):
+ return overview_payload(_purpose(purpose))
+
+
+@router.post("/{purpose}/reset")
+def reset_items(purpose: str, session: dict = Depends(require_admin_dep)):
+ try:
+ return reset_seed_drafts(_purpose(purpose))
+ except CatalogError as exc:
+ _http(exc)
+
+
+@router.post("/{purpose}/preview")
+def preview_items(purpose: str, body: PreviewRequest, session: dict = Depends(require_admin_dep)):
+ checked = _purpose(purpose)
+ selection = body.generation_selection or default_selection_ids(checked)
+ try:
+ return preview_selection(selection, purpose=checked)
+ except GenerationPolicyError as exc:
+ _http(exc)
+
+
+@router.post("/{purpose}")
+def create_item(purpose: str, body: GuidelineWrite, session: dict = Depends(require_admin_dep)):
+ try:
+ return create_guideline(_purpose(purpose), body.slot or "", body.model_dump())
+ except CatalogError as exc:
+ _http(exc)
+
+
+@router.get("/{purpose}/{guideline_id}")
+def read_item(purpose: str, guideline_id: str, session: dict = Depends(require_admin_dep)):
+ _purpose(purpose)
+ try:
+ item = get_guideline(guideline_id, include_instruction=True)
+ except CatalogError as exc:
+ _http(exc)
+ if item.get("purpose") != purpose:
+ raise HTTPException(404, "Ausprägung nicht gefunden.")
+ return item
+
+
+@router.put("/{purpose}/{guideline_id}")
+def write_item(purpose: str, guideline_id: str, body: GuidelineWrite, session: dict = Depends(require_admin_dep)):
+ _purpose(purpose)
+ try:
+ payload = {key: value for key, value in body.model_dump().items() if value is not None}
+ payload.pop("slot", None)
+ return update_guideline(guideline_id, payload)
+ except CatalogError as exc:
+ _http(exc)
+
+
+@router.post("/{purpose}/{guideline_id}/clone")
+def clone_item(purpose: str, guideline_id: str, session: dict = Depends(require_admin_dep)):
+ _purpose(purpose)
+ try:
+ return clone_guideline(guideline_id)
+ except CatalogError as exc:
+ _http(exc)
+
+
+@router.post("/{purpose}/{guideline_id}/publish")
+def publish_item(purpose: str, guideline_id: str, session: dict = Depends(require_admin_dep)):
+ _purpose(purpose)
+ try:
+ return publish_guideline(guideline_id)
+ except CatalogError as exc:
+ _http(exc)
+
+
+@router.post("/{purpose}/{guideline_id}/archive")
+def archive_item(purpose: str, guideline_id: str, session: dict = Depends(require_admin_dep)):
+ _purpose(purpose)
+ try:
+ return archive_guideline(guideline_id)
+ except CatalogError as exc:
+ _http(exc)
+
+
+@router.post("/{purpose}/{guideline_id}/default")
+def default_item(purpose: str, guideline_id: str, session: dict = Depends(require_admin_dep)):
+ _purpose(purpose)
+ try:
+ return set_default_guideline(guideline_id)
+ except CatalogError as exc:
+ _http(exc)
+
+
+@router.delete("/{purpose}/{guideline_id}")
+def delete_item(purpose: str, guideline_id: str, session: dict = Depends(require_admin_dep)):
+ _purpose(purpose)
+ try:
+ delete_guideline(guideline_id)
+ return {"ok": True}
+ except CatalogError as exc:
+ _http(exc)
diff --git a/backend/routers/journal.py b/backend/routers/journal.py
index bf07014..a6c469a 100644
--- a/backend/routers/journal.py
+++ b/backend/routers/journal.py
@@ -10,6 +10,7 @@ from dialogue_turn import run_turn, visible_for_role
from privacy_gateway import GatewayRequest, inspect
from engine import EngineError
from journal_generate import generate_draft
+from journal_generation_policy import settings_payload
from journal_opening import maybe_open_journal_conversation
from journal_policy import PolicyError
from journal_store import (
@@ -102,6 +103,8 @@ class TurnWrite(BaseModel):
class GenerateWrite(BaseModel):
conversation_ids: list[str] | None = None
include_existing: bool = False
+ generation_selection: dict | None = None
+ remember_generation_selection: bool = False
class EntryWrite(BaseModel):
@@ -309,6 +312,11 @@ def conversation_turn(conversation_id: str, body: TurnWrite, session: dict = Dep
_http(exc)
+@router.get("/generation-settings")
+def read_generation_settings(session: dict = Depends(require_auth)):
+ return settings_payload(session["profile_id"])
+
+
@router.post("/days/{journal_day_id}/generate")
def generate(journal_day_id: str, body: GenerateWrite, session: dict = Depends(require_auth)):
try:
@@ -319,6 +327,8 @@ def generate(journal_day_id: str, body: GenerateWrite, session: dict = Depends(r
conversation_ids=body.conversation_ids,
include_existing=body.include_existing,
explicit=True,
+ generation_selection=body.generation_selection,
+ remember_generation_selection=body.remember_generation_selection,
),
session.get("role"),
)
diff --git a/backend/schema.sql b/backend/schema.sql
index 2562812..81a36cc 100644
--- a/backend/schema.sql
+++ b/backend/schema.sql
@@ -251,6 +251,7 @@ CREATE TABLE IF NOT EXISTS journal_drafts (
source_message_ids TEXT NOT NULL DEFAULT '[]',
as_of TEXT NOT NULL,
superseded_at TEXT,
+ generation_snapshot TEXT NOT NULL DEFAULT '{}',
created TEXT NOT NULL DEFAULT (datetime('now'))
);
@@ -463,3 +464,33 @@ CREATE TABLE IF NOT EXISTS provider_settings (
no_train INTEGER NOT NULL DEFAULT 1,
updated TEXT NOT NULL DEFAULT (datetime('now'))
);
+
+CREATE TABLE IF NOT EXISTS journal_generation_selection (
+ profile_id TEXT PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
+ transformation_id TEXT NOT NULL,
+ detail_id TEXT NOT NULL,
+ voice_id TEXT NOT NULL,
+ narrative_id TEXT NOT NULL,
+ updated TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS generation_guidelines (
+ id TEXT PRIMARY KEY,
+ purpose TEXT NOT NULL,
+ slot TEXT NOT NULL,
+ guideline_key TEXT NOT NULL,
+ label TEXT NOT NULL,
+ summary TEXT NOT NULL DEFAULT '',
+ instruction TEXT NOT NULL,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('draft', 'active', 'archived')),
+ revision INTEGER NOT NULL DEFAULT 1,
+ cloned_from TEXT,
+ is_default INTEGER NOT NULL DEFAULT 0,
+ is_system_seed INTEGER NOT NULL DEFAULT 0,
+ seed_id TEXT NOT NULL DEFAULT '',
+ seed_revision TEXT NOT NULL DEFAULT '',
+ used_at TEXT,
+ created TEXT NOT NULL DEFAULT (datetime('now')),
+ updated TEXT NOT NULL DEFAULT (datetime('now'))
+);
diff --git a/backend/tests/test_frame.py b/backend/tests/test_frame.py
index 553b6dd..52e7f9a 100644
--- a/backend/tests/test_frame.py
+++ b/backend/tests/test_frame.py
@@ -223,8 +223,10 @@ def main() -> None:
"space_title",
"writing_profile",
"style_examples",
- "editorial_mode",
- "editorial_instructions",
+ "transformation_instructions",
+ "detail_instructions",
+ "voice_instructions",
+ "narrative_instructions",
"interaction_hint",
},
"system and mvp context keys",
diff --git a/backend/tests/test_journal_budget.py b/backend/tests/test_journal_budget.py
index de9e8b7..5def8e3 100644
--- a/backend/tests/test_journal_budget.py
+++ b/backend/tests/test_journal_budget.py
@@ -874,7 +874,7 @@ def test_generate_flow(client: TestClient, headers: dict) -> None:
expect("assistant:" not in (gen.json().get("body") or ""), "draft body has no assistant lines")
expect("WRITING_PROFILE" in narrate or "Neutraler Journalstil" in narrate or "Core:" in narrate, "writing profile reaches generate")
expect("STYLE_EXAMPLES" in narrate, "style examples are labeled separately from day facts")
- expect("Erzählmerkmale" not in (narrate.split("CURRENT_DAY_SOURCES")[0] if "CURRENT_DAY_SOURCES" 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()
short_recorder = install_test_recorder()
@@ -995,7 +995,7 @@ def test_generate_flow(client: TestClient, headers: dict) -> None:
reset_catalog()
-def test_generate_identity_leak_local_fallback(client: TestClient, headers: dict, profile_id: str) -> None:
+def test_generate_attested_cleartext_is_normalized(client: TestClient, headers: dict, profile_id: str) -> None:
from identity_store import remember_mapping
from providers import ChatResult
@@ -1016,13 +1016,15 @@ def test_generate_identity_leak_local_fallback(client: TestClient, headers: dict
headers=headers,
json={"body": "Heute war ich mit Anna am Markt, danach Kirschen."},
)
- expect(turn.status_code == 200, f"identity-leak setup turn {turn.status_code}")
+ expect(turn.status_code == 200, f"identity-cleartext setup turn {turn.status_code}")
+ calls = {"n": 0}
def leak(_messages, _policy):
+ calls["n"] += 1
return ChatResult(
content="Anna stand den ganzen Nachmittag am Markt.",
model="fake",
- usage={},
+ usage={"prompt_tokens": 11, "completion_tokens": 9, "total_tokens": 20, "cost": 0.004},
context_compression="disabled",
)
@@ -1032,24 +1034,20 @@ def test_generate_identity_leak_local_fallback(client: TestClient, headers: dict
headers=headers,
json={"conversation_ids": [conv.json()["id"]]},
)
- expect(gen.status_code == 200, f"identity leak still yields a local draft {gen.text}")
+ expect(gen.status_code == 200, f"attested cleartext is accepted locally {gen.text}")
payload = gen.json()
leak_log = payload.get("run_log") or (payload.get("trace") or {}).get("log") or []
- expect(any(item.get("kind") == "retry" for item in leak_log), "identity leak records a retry")
- expect(
- any(item.get("reason") == "identity_leak_blocked" for item in leak_log),
- "identity leak records local fallback",
- )
+ expect(calls["n"] == 1, "attested cleartext does not retry")
+ expect(not any(item.get("kind") == "retry" for item in leak_log), "no privacy retry event")
body = payload.get("body") or ""
- expect("Markt" in body and "Kirschen" in body, "local draft keeps attested user wording")
- expect("Anna" in body, "local draft may keep names that the user actually wrote")
+ expect("Anna" in body, "source-attested name is demasked")
+ expect("Markt" in body, "model wording is kept after normalization")
stages = {item.get("purpose"): item for item in (payload.get("trace") or {}).get("stages") or []}
reconstruct = stages.get("local_source_artifact") or {}
narrate = stages.get("journal_generate") or {}
expect(reconstruct.get("status") == "local_ok", "stage 1 stays local and does not call reconstruct")
- expect(narrate.get("guard") == "identity_leak_blocked", "stage 2 does not use the leaking reply")
- expect(sum(1 for item in leak_log if item.get("kind") == "retry") == 1, "active leak retries at most once")
- expect(sum(1 for item in leak_log if item.get("kind") == "model_call") <= 2, "retry is the only extra model call")
+ expect(narrate.get("model_text_accepted") is True, "stage 2 accepts the normalized model text")
+ expect((payload.get("trace") or {}).get("generate_calls") == 1 or (narrate.get("generate_calls") == 1), "exactly one generate call")
def test_generate_inactive_mapping_keeps_model_text(client: TestClient, headers: dict, profile_id: str) -> None:
@@ -1091,17 +1089,29 @@ def test_generate_inactive_mapping_keeps_model_text(client: TestClient, headers:
headers=headers,
json={"conversation_ids": [conv.json()["id"]]},
)
- expect(gen.status_code == 200, f"inactive mapping generate {gen.text}")
- payload = gen.json()
- run_log = payload.get("run_log") or (payload.get("trace") or {}).get("log") or []
+ expect(gen.status_code == 409, f"inactive mapping generate {gen.text}")
+ detail = gen.json().get("detail") or {}
+ expect(detail.get("code") == "journal_generation_not_accepted", "historical-only name is not stored as a draft")
+ expect(detail.get("message") == "Generierung nicht übernommen.", "API names the rejection")
+ diag = detail.get("diagnostics") or {}
+ run_log = diag.get("log") or (diag.get("trace") or {}).get("log") or []
expect(calls["n"] == 1, "inactive mapping does not trigger a retry")
expect(not any(item.get("kind") == "retry" for item in run_log), "inactive mapping does not retry")
expect(
any(item.get("reason") == "unattested_identity" for item in run_log),
"invented historical name is unattested journal content",
)
- expect("Clarissa" not in (payload.get("body") or ""), "unattested name is not accepted as narration")
- expect("Markt" in (payload.get("body") or "") and "Kirschen" in (payload.get("body") or ""), "fallback keeps user sources")
+ expect(diag.get("model_text_accepted") is False, "unattested model text is not accepted")
+ day_after = client.get(f"/api/journal/days/{day.json()['day']['id']}", headers=headers)
+ expect(not (day_after.json().get("current_draft")), "rejected generate does not insert a draft")
+ trace = diag.get("trace") or {}
+ expect(trace.get("abort_reason") == "unattested_identity", "abort reason remains on the error trace")
+ expect(
+ (trace.get("generate_calls") == 1)
+ or ((trace.get("budget") or {}).get("generate_calls") == 1)
+ or diag.get("generate_calls") == 1,
+ "error trace keeps the generate-call count",
+ )
def test_traces_are_request_scoped(client: TestClient, headers: dict) -> None:
@@ -1247,7 +1257,7 @@ def main() -> None:
profile_id = setup.json()["profile_id"]
test_task_brief_and_dedupe(client, headers, profile_id)
test_generate_flow(client, headers)
- test_generate_identity_leak_local_fallback(client, headers, profile_id)
+ test_generate_attested_cleartext_is_normalized(client, headers, profile_id)
test_generate_inactive_mapping_keeps_model_text(client, headers, profile_id)
test_traces_are_request_scoped(client, headers)
test_two_conversations_same_day(client, headers)
diff --git a/backend/tests/test_journal_editorial.py b/backend/tests/test_journal_editorial.py
index 910914a..4e46afe 100644
--- a/backend/tests/test_journal_editorial.py
+++ b/backend/tests/test_journal_editorial.py
@@ -20,11 +20,9 @@ from db import get_db, init_db
from engine import load_active_prompt
from identity_store import remember_mapping
from journal_editorial import (
- NOTES_TO_JOURNAL,
- PROSE_EDIT,
- choose_editorial_mode,
- editorial_instructions,
+ GENERATE_SEED_REVISION,
format_style_examples,
+ incomplete_syntax_markers,
lexical_similarity,
narration_sources_text,
select_journal_style_examples,
@@ -43,7 +41,13 @@ from writing_profile_store import (
)
-SEED_REVISION = "2026-08-27-journal-placeholders-v1"
+SEED_REVISION = GENERATE_SEED_REVISION
+MIXED_SOURCES_INSTRUCTION = (
+ "Die 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."
+)
def expect(ok: bool, message: str) -> None:
@@ -56,43 +60,36 @@ def header(token: str) -> dict:
return {"X-Auth-Token": token}
-def test_mode_choice() -> None:
- expect(choose_editorial_mode(["Ich ging zum Markt. Es war voll."]) == PROSE_EDIT, "narrative sentences select prose_edit")
- expect(choose_editorial_mode(["markt", "kirschen", "hafen"]) == NOTES_TO_JOURNAL, "fragments select notes_to_journal")
- expect(
- choose_editorial_mode(["Ich war am Markt.", "Kirschen gekauft.", "hafen später"]) == PROSE_EDIT,
- "mixed default is prose_edit when at least half the blocks have sentence punctuation",
- )
- expect(
- choose_editorial_mode(["markt", "kirschen", "Später der Hafen."]) == NOTES_TO_JOURNAL,
- "mixed default is notes_to_journal when the majority lacks sentence punctuation",
- )
- prose = editorial_instructions(PROSE_EDIT)
- notes = editorial_instructions(NOTES_TO_JOURNAL)
- expect("Gute Formulierungen bewahren" in prose, "prose_edit keeps good wording")
- expect("zusammenhängende Journalprosa" in notes, "notes_to_journal asks for connected prose")
- expect(prose != notes, "modes produce different instructions")
- expect("nicht inklusive ihrer Fehler" in notes or "Fehler hintereinanderkopieren" in notes, "notes must not be concatenated with errors")
+def test_syntax_diagnostics() -> None:
+ expect(incomplete_syntax_markers("Danach sprach ich mit dem.") > 0, "dangling determiner is diagnostic")
+ expect(incomplete_syntax_markers("Danach sprach ich und kam zurück.") == 0, "complete sentence is not flagged")
def test_prompt_contract() -> None:
init_db()
prompt = load_active_prompt("mvp.journal_generate")
text = prompt.get("template") or ""
- expect("Faktentreue ist nicht Wortlauttreue" in text, "prompt separates fact fidelity from wording")
+ expect("INHALTSTREUE" in text, "prompt keeps hard content rules")
expect("CURRENT_DAY_SOURCES" in text, "prompt labels current-day facts")
- expect("WRITING_PROFILE" in text, "prompt labels the writing profile")
expect("STYLE_EXAMPLES" in text, "prompt labels style examples")
- expect("EDITORIAL_MODE" in text, "prompt exposes editorial mode")
- expect("keine geschützten Fakten" in text, "prompt says typos are not protected facts")
- expect("{{editorial_instructions}}" in text, "prompt injects mode-specific instructions")
+ expect("WRITING_PROFILE" in text, "prompt labels the writing profile")
+ expect("{{transformation_instructions}}" in text, "prompt injects compiled transformation instructions")
+ expect("{{detail_instructions}}" in text, "prompt injects compiled detail instructions")
+ expect("{{voice_instructions}}" in text, "prompt injects compiled voice instructions")
+ expect("{{narrative_instructions}}" in text, "prompt injects compiled narrative instructions")
+ expect("{{source_mode_instructions}}" not in text, "prompt has no source-mode placeholder")
+ expect(MIXED_SOURCES_INSTRUCTION in text, "prompt contains the mixed-source instruction")
+ expect("{{writing_profile}}" in text, "prompt injects writing profile")
expect("{{style_examples}}" in text, "prompt injects style examples")
+ expect("{{reconstruction}}" in text, "prompt injects current-day sources")
+ expect("{{existing_text}}" in text, "prompt injects existing text")
expect("[[…" not in text and "[[..." not in text, "prompt must not teach ellipsis placeholders")
- expect("zeichengetreu" in text, "prompt asks to copy existing placeholders unchanged")
- expect("ich gieng zum laden" in text, "prompt includes a synthetic prose_edit example")
- expect("Im Laden holte ich Brot" in text, "prompt includes a synthetic notes_to_journal example")
+ expect("ich gieng zum laden" not in text, "synthetic prose_edit example is gone")
+ expect("nachbarhund im garten" not in text, "synthetic notes_to_journal example is gone")
expect("Nur den verifizierten Nutzerwortlaut" not in text, "old wording-as-output rule is gone")
expect("Unsicherheiten im Wortlaut" not in text, "old keep-uncertainty-in-wording rule is gone")
+ expect("EDITORIAL_MODE" not in text, "source mode is no longer a labeled user setting")
+ expect("Faktentreue ist nicht Wortlauttreue" not in text, "old redundant fidelity lecture is gone")
with get_db() as conn:
row = conn.execute(
"SELECT seed_revision, template, default_template FROM ai_prompts WHERE slug = ?",
@@ -163,8 +160,6 @@ def test_budget_pack_drops_examples_first() -> None:
assembled = {
"writing_profile": "Core: kurze Sätze.",
"reconstruction": "Heute Markt.",
- "editorial_mode": PROSE_EDIT,
- "editorial_instructions": "x",
"style_examples": "",
"existing_text": "",
}
@@ -209,7 +204,7 @@ def intern_of(payload: dict) -> str:
def main() -> None:
- test_mode_choice()
+ test_syntax_diagnostics()
test_prompt_contract()
test_sources_and_examples_are_separated()
test_unattested_covers_title_and_body()
@@ -256,16 +251,22 @@ def main() -> None:
)
expect(gen.status_code == 200, f"generate {gen.status_code}")
intern = intern_of(gen.json())
- expect("EDITORIAL_MODE: prose_edit" in intern, "narrative source selects prose_edit")
- expect("Gute Formulierungen bewahren" in intern, "prose_edit instructions reach 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("STYLE_EXAMPLES" in intern, "style examples are labeled")
expect("WRITING_PROFILE" in intern, "writing profile is labeled")
+ expect("Überarbeite den Text substanziell" in intern, "default transformation policy reaches the prompt")
+ expect("Erhalte sämtliche belegten Ereignisse" in intern, "default detail policy reaches the prompt")
expect("Neutraler Journalstil" in intern, "without a confirmed profile the neutral fallback is used")
- expect("zimlich" in intern.split("CURRENT_DAY_SOURCES")[-1], "today remains content, including typos")
- expect("zimlich" not in intern.split("CURRENT_DAY_SOURCES")[0], "today is not a style authority")
+ expect("zimlich" in intern.split("\nCURRENT_DAY_SOURCES\n")[-1], "today remains content, including typos")
+ expect("zimlich" not in intern.split("\nCURRENT_DAY_SOURCES\n")[0], "today is not a style authority")
expect(sum(1 for item in (gen.json().get("run_log") or []) if item.get("kind") == "model_call") == 1, "normal path is one generate call")
- expect((gen.json().get("trace") or {}).get("editorial_mode") == PROSE_EDIT, "editorial mode is in the admin trace")
+ expect("editorial_mode" not in (gen.json().get("trace") or {}), "trace has no abandoned editorial mode")
+ expect((gen.json().get("trace") or {}).get("narration_source") == "model", "accepted model path is recorded")
+ expect((gen.json().get("trace") or {}).get("prompt_revision") == SEED_REVISION, "prompt revision is in the admin trace")
+ expect((gen.json().get("trace") or {}).get("writing_profile", {}).get("neutral_fallback") is True, "unconfirmed profile is visible as fallback")
+ expect((gen.json().get("trace") or {}).get("style_examples", {}).get("count") == 0, "no historical examples yet")
+ expect((gen.json().get("trace") or {}).get("dropped_optional_blocks") == [], "nothing dropped on a small day")
notes_day = client.post(
f"/api/journal/spaces/{space.json()['id']}/days",
@@ -289,9 +290,8 @@ def main() -> None:
)
expect(notes_gen.status_code == 200, f"notes generate {notes_gen.text}")
notes_intern = intern_of(notes_gen.json())
- expect("EDITORIAL_MODE: notes_to_journal" in notes_intern, "fragments select notes_to_journal")
- expect("zusammenhängende Journalprosa" in notes_intern, "notes mode reaches the model")
- expect("Gute Formulierungen bewahren" not in notes_intern, "prose_edit instructions are not used for notes")
+ expect(MIXED_SOURCES_INSTRUCTION in notes_intern, "notes use the same mixed-source instruction")
+ expect("markt" in notes_intern.lower() and "kirschen" in notes_intern.lower(), "notes sources remain complete")
import_text(
profile_id,
@@ -360,7 +360,7 @@ def main() -> None:
expect(body.lower().count("markt") <= 2, "repetition is allowed to be reduced")
expect("traurig" not in body.lower() and "weil" not in body.lower(), "no invented feeling or cause in the patched rewrite")
intern2 = intern_of(second.json())
- expect("trockener Schnitt" in intern2.split("CURRENT_DAY_SOURCES")[0], "confirmed writing profile reaches generate")
+ expect("trockener Schnitt" in intern2.split("\nCURRENT_DAY_SOURCES\n")[0], "confirmed writing profile reaches generate")
expect("Hafen blieb hinter der Fähre" not in body, "historical style facts are not copied into today")
expect(
sum(1 for item in (second.json().get("run_log") or []) if item.get("kind") == "model_call") == 1,
@@ -386,6 +386,103 @@ def main() -> None:
expect("Am Markt holte ich Kirschen" in notes_body, "notes become connected prose")
expect("kirschen\nspäter" not in notes_body.lower(), "notes are not concatenated as fragments")
+ broken_day = client.post(
+ f"/api/journal/spaces/{space.json()['id']}/days",
+ headers=headers,
+ json={"calendar_date": "2026-08-24"},
+ )
+ broken_conv = client.post(
+ f"/api/journal/days/{broken_day.json()['day']['id']}/conversations",
+ headers=headers,
+ json={"title": "Bruch"},
+ )
+ client.post(
+ f"/api/journal/conversations/{broken_conv.json()['id']}/turn",
+ headers=headers,
+ json={"body": "ich gieng zum laden. danach sprach ich mit dem und kam zurück. es war kald."},
+ )
+
+ def rebuilt(_messages, _policy):
+ return ChatResult(
+ content="Ladengang\n\nIch ging zum Laden. Danach sprach ich und kam zurück. Es war kalt.",
+ model="fake",
+ usage={},
+ context_compression="disabled",
+ )
+
+ with patch("privacy_gateway.complete_model", rebuilt):
+ broken_out = client.post(
+ f"/api/journal/days/{broken_day.json()['day']['id']}/generate",
+ headers=headers,
+ json={"conversation_ids": [broken_conv.json()["id"]]},
+ )
+ broken_body = broken_out.json().get("body") or ""
+ expect("gieng" not in broken_body.lower(), "general spelling error is not kept")
+ expect("kald" not in broken_body.lower(), "second general spelling error is not kept")
+ expect("mit dem" not in broken_body.lower(), "dangling determiner is not kept")
+ expect("kam zurück" in broken_body, "attested continuation stays")
+ expect(incomplete_syntax_markers(broken_body) == 0, "patched rewrite has no incomplete syntax")
+ expect((broken_out.json().get("trace") or {}).get("incomplete_syntax") == 0, "incomplete syntax is a diagnostic")
+
+ weight_day = client.post(
+ f"/api/journal/spaces/{space.json()['id']}/days",
+ headers=headers,
+ json={"calendar_date": "2026-08-23"},
+ )
+ weight_conv = client.post(
+ f"/api/journal/days/{weight_day.json()['day']['id']}/conversations",
+ headers=headers,
+ json={"title": "Notizen"},
+ )
+ client.post(
+ f"/api/journal/conversations/{weight_conv.json()['id']}/turn",
+ headers=headers,
+ json={"body": "morgens tee\nspäter markt\ngegen abend unerwartet der nachbarhund im garten"},
+ )
+
+ def weighted(_messages, _policy):
+ return ChatResult(
+ content=(
+ "Nachbarhund\n\n"
+ "Morgens trank ich Tee, später war ich am Markt. "
+ "Es war ein gewöhnlicher Tag – bis gegen Abend unerwartet der Nachbarhund im Garten war."
+ ),
+ model="fake",
+ usage={},
+ context_compression="disabled",
+ )
+
+ with patch("privacy_gateway.complete_model", weighted):
+ weight_out = client.post(
+ f"/api/journal/days/{weight_day.json()['day']['id']}/generate",
+ headers=headers,
+ json={"conversation_ids": [weight_conv.json()["id"]]},
+ )
+ weight_intern = intern_of(weight_out.json())
+ expect(MIXED_SOURCES_INSTRUCTION in weight_intern, "fragment notes still use the unified instruction")
+ weight_body = weight_out.json().get("body") or ""
+ expect("bis gegen Abend" in weight_body or "unerwartet" in weight_body, "attested standout may be weighted")
+ expect("morgens tee\nspäter markt" not in weight_body.lower(), "notes are not a concatenated list")
+ expect("glücklich" not in weight_body.lower() and "weil" not in weight_body.lower(), "weighting does not invent feeling or cause")
+
+ update_facet(profile_id, "core", value="Lange, ruhig fließende Sätze, behutsame Wortwahl, leise Reflexion.")
+ set_lifecycle(profile_id, "confirmed")
+ brief_b = compile_task_brief(profile_id)
+ expect("fließende Sätze" in brief_b, "second confirmed core compiles")
+ with patch("privacy_gateway.complete_model", rewritten):
+ third = client.post(
+ f"/api/journal/days/{day.json()['day']['id']}/generate",
+ headers=headers,
+ json={"conversation_ids": [conv.json()["id"]]},
+ )
+ intern3 = intern_of(third.json())
+ style3 = intern3.split("\nCURRENT_DAY_SOURCES\n")[0]
+ expect("fließende Sätze" in style3, "second writing profile reaches the rendered prompt")
+ expect("trockener Schnitt" not in style3, "replaced core is not still the style authority")
+ expect((third.json().get("trace") or {}).get("writing_profile", {}).get("has_core") is True, "core presence is in the trace")
+ expect((third.json().get("trace") or {}).get("writing_profile", {}).get("present") is True, "confirmed profile is marked present")
+ expect(sum(1 for item in (third.json().get("run_log") or []) if item.get("kind") == "model_call") == 1, "profile A/B still uses one generate call")
+
def invent_title(_messages, _policy):
return ChatResult(
content="Hanna am Hafen\n\nIch ging zum Markt, auf dem es ziemlich voll war.",
@@ -400,8 +497,13 @@ def main() -> None:
headers=headers,
json={"conversation_ids": [conv.json()["id"]]},
)
- expect(any(item.get("reason") == "unattested_identity" for item in (blocked.json().get("run_log") or [])), "title identity uses local fallback")
- expect("Hanna" not in (blocked.json().get("title") or "") and "Hanna" not in (blocked.json().get("body") or ""), "unattested title identity is not kept")
+ expect(blocked.status_code == 409, f"unattested title {blocked.text}")
+ detail = blocked.json().get("detail") or {}
+ expect(detail.get("code") == "journal_generation_not_accepted", "unattested title is not stored as a draft")
+ expect(detail.get("message") == "Generierung nicht übernommen.", "API names the rejection")
+ log = (detail.get("diagnostics") or {}).get("log") or []
+ expect(any(item.get("reason") == "unattested_identity" for item in log), "title identity is a provenance reject")
+ expect((detail.get("diagnostics") or {}).get("trace", {}).get("model_text_accepted") is False, "model text is not accepted")
with get_db() as conn:
conn.execute(
@@ -416,7 +518,7 @@ def main() -> None:
"SELECT default_template, seed_revision FROM ai_prompts WHERE slug = ?",
("mvp.journal_generate",),
).fetchone()
- expect("Faktentreue ist nicht Wortlauttreue" 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"] == SEED_REVISION, "revision updates even when template is custom")
print("journal editorial tests passed.")
diff --git a/backend/tests/test_journal_eval.py b/backend/tests/test_journal_eval.py
index e9870c5..c6de8b1 100644
--- a/backend/tests/test_journal_eval.py
+++ b/backend/tests/test_journal_eval.py
@@ -15,14 +15,19 @@ os.environ["KANSHO_FAKE_DETECT"] = "1"
Path(os.environ["KANSHO_DB_PATH"]).unlink(missing_ok=True)
from db import init_db
+from journal_editorial import GENERATE_SEED_REVISION
from journal_eval import (
+ FIXTURES,
+ PROFILE_A,
+ PROFILE_B,
SYNTHETIC_PROSE,
SYNTHETIC_TYPOS,
VARIANT_BASELINE,
VARIANT_CURRENT,
VARIANT_PREVIOUS,
+ compare_synthetic,
+ fixture_context,
score_output,
- synthetic_context,
variant_templates,
)
@@ -37,12 +42,33 @@ def main() -> None:
init_db()
templates = variant_templates()
expect(set(templates) == {VARIANT_BASELINE, VARIANT_PREVIOUS, VARIANT_CURRENT}, "three comparison variants exist")
- expect("Überarbeite diesen Rohtext" in templates[VARIANT_BASELINE], "baseline is a simple rewrite prompt")
+ expect("Erstelle aus diesen Angaben einen ansprechenden persönlichen Tagebucheintrag" in templates[VARIANT_BASELINE], "baseline is a simple rewrite prompt")
expect("CURRENT_DAY_SOURCES" in templates[VARIANT_CURRENT], "current variant uses the production prompt")
- expect("Faktentreue ist nicht Wortlauttreue" in templates[VARIANT_CURRENT], "current variant has the new contract")
+ expect("INHALTSTREUE" in templates[VARIANT_CURRENT], "current variant has the new contract")
+ expect("{{transformation_instructions}}" in templates[VARIANT_CURRENT], "current variant compiles transformation policy")
+ expect("{{source_mode_instructions}}" not in templates[VARIANT_CURRENT], "current variant has no source-mode placeholder")
+ expect("Zwinge nicht den gesamten Tag in einen einzigen Quellenmodus." in templates[VARIANT_CURRENT], "current variant has mixed-source instruction")
+ expect("ich gieng zum laden" not in templates[VARIANT_CURRENT], "current variant has no synthetic examples")
+ expect(GENERATE_SEED_REVISION == "2026-08-27-journal-mixed-sources-v1", "eval tracks the seeded revision constant")
- context = synthetic_context(SYNTHETIC_PROSE)
- expect(context["editorial_mode"] in {"prose_edit", "notes_to_journal"}, "eval context has an editorial mode")
+ required_classes = {
+ "already_narrative_with_errors",
+ "bullet_points_and_fragments",
+ "plan_versus_completion",
+ "negation_and_uncertainty",
+ "correction_of_earlier_claim",
+ "imprecise_time",
+ "outstanding_event_among_everyday",
+ "recurring_people_and_projects",
+ "incomplete_source_rebuildable",
+ }
+ got = {item["class"] for item in FIXTURES}
+ expect(required_classes <= got, f"all required fixture classes exist, missing {required_classes - got}")
+ expect(all("example.test" not in item["source"].lower() for item in FIXTURES), "fixtures stay synthetic")
+
+ context = fixture_context(SYNTHETIC_PROSE)
+ expect("source_mode_instructions" not in context, "eval context has no source-mode instruction")
+ expect("editorial_mode" not in context, "eval context has no editorial mode")
copied = score_output(SYNTHETIC_PROSE, SYNTHETIC_PROSE, typos=SYNTHETIC_TYPOS)
expect(copied["lexical_similarity"] == 1.0, "identical text is similarity 1")
expect(copied["spelling_typos_remaining"] == list(SYNTHETIC_TYPOS) or "zimlich" in copied["spelling_typos_remaining"], "copy keeps typos")
@@ -55,6 +81,28 @@ def main() -> None:
expect(improved["lexical_similarity"] < 1.0, "rewrite is not identical")
expect("markt" in [item.lower() for item in improved["lost_info_tokens"]] or improved["fact_token_keep"] > 0.2, "fact keep is scored")
expect("private" not in str(improved).lower(), "synthetic scores contain no private fixtures")
+
+ notes = next(item for item in FIXTURES if item["id"] == "notes_fragments")
+ expect("markt" in notes["source"].lower(), "notes fixture stays notes-shaped")
+ incomplete = next(item for item in FIXTURES if item["id"] == "incomplete_clause")
+ expect("danach sprach ich mit dem" in incomplete["source"].lower(), "incomplete fixture stays incomplete prose")
+
+ report = compare_synthetic(live=False)
+ expect(report["live"] is False, "default eval is offline")
+ expect(report["live_quality_confirmed"] is False, "offline run does not confirm live quality")
+ expect(report["winner_declared"] is False, "harness does not declare a winner")
+ expect(len(report["fixtures"]) == len(FIXTURES), "offline report covers every fixture")
+ first = report["fixtures"][0]
+ expect("human_blind" in first and "prompt_1" in first["human_blind"], "each fixture has a blind pair")
+ expect(first["human_blind"]["hidden_mapping"]["prompt_1"] == VARIANT_CURRENT, "mapping stays machine-side")
+ expect("Kanshō habe gewonnen" not in report["note"], "no victory claim")
+ expect(any(item["variant"] == VARIANT_CURRENT and item["fake_provider"] for item in first["variants"]), "offline current variant is fake")
+
+ ab = compare_synthetic(live=False, profile_ab=True)
+ expect(ab["profile_ab"]["same_facts"] is True, "profile A/B keeps facts identical")
+ expect(ab["profile_ab"]["prompts_differ"] is True, "rendered prompts contain different style briefs")
+ expect(PROFILE_A[:20] != PROFILE_B[:20], "synthetic profiles are distinct")
+ expect("Live-Prosa" in (ab["profile_ab"]["note"] or ""), "offline A/B does not claim live prose")
print("journal eval tests passed.")
diff --git a/backend/tests/test_journal_generation_policy.py b/backend/tests/test_journal_generation_policy.py
new file mode 100644
index 0000000..26d1427
--- /dev/null
+++ b/backend/tests/test_journal_generation_policy.py
@@ -0,0 +1,961 @@
+"""Named journal generation guidelines: selection, snapshot, mixed sources."""
+from __future__ import annotations
+
+import os
+import re
+import sys
+import tempfile
+from pathlib import Path
+from unittest.mock import patch
+
+ROOT = Path(__file__).resolve().parents[1]
+FRONTEND = ROOT.parent / "frontend"
+sys.path.insert(0, str(ROOT))
+
+os.environ["KANSHO_DB_PATH"] = str(Path(tempfile.gettempdir()) / "kansho-journal-guidelines-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 engine import load_active_prompt
+from identity_store import confirm_identity, remember_mapping
+from journal_editorial import GENERATE_SEED_REVISION
+from journal_generation_policy import (
+ PURPOSE_JOURNAL,
+ SELECTION_KEYS,
+ SLOT_TO_ID_KEY,
+ CatalogError,
+ GenerationPolicyError,
+ archive_guideline,
+ clone_guideline,
+ compile_selection,
+ create_guideline,
+ default_selection_ids,
+ get_guideline,
+ get_or_create_selection,
+ list_guidelines,
+ load_seed_document,
+ load_selection,
+ overview_payload,
+ publish_guideline,
+ save_selection,
+ seed_generation_instructions,
+ snapshot_summary,
+ update_guideline,
+ validate_selection,
+)
+from journal_generate import unattested_journal_content
+from journal_store import current_draft
+from main import app
+from privacy_gateway import install_test_recorder, reset_debug
+from providers import ChatResult
+
+
+PREVIOUS_PROMPT_CHARS = 3468
+HARD_FACTS = (
+ "keine neuen Tatsachen",
+ "Plan und Vollzug",
+ "Verneinungen",
+ "Unsicherheiten",
+ "STYLE_EXAMPLES dienen ausschließlich als Stilreferenz",
+ "[[PERSON:01]]",
+)
+BANNED_CODE_PHRASES = (
+ "Überarbeite den Text substanziell",
+ "Erhalte sämtliche belegten Ereignisse",
+ "künstlich zu literarisieren",
+ "Modus prose_edit",
+ "Modus notes_to_journal",
+ "Korrigiere nur Rechtschreibung, Grammatik und Zeichensetzung",
+)
+BANNED_RUNTIME_TOKENS = (
+ "transformation_strength",
+ "detail_retention",
+ "voice_strength",
+ "narrative_shaping",
+ "min_value",
+ "max_value",
+ "remember_generation_policy",
+ "generation_policy",
+)
+SEED = load_seed_document()
+MIXED_SOURCES_INSTRUCTION = (
+ "Die 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."
+)
+REQUIRED_KEYS = {
+ ("transformation", "correction"),
+ ("transformation", "copyedit"),
+ ("transformation", "reshape"),
+ ("transformation", "substantial"),
+ ("detail", "compact"),
+ ("detail", "selected"),
+ ("detail", "broad"),
+ ("detail", "complete"),
+ ("voice", "neutral"),
+ ("voice", "light"),
+ ("voice", "noticeable"),
+ ("voice", "clear"),
+ ("narrative", "chronicle"),
+ ("narrative", "structured"),
+ ("narrative", "weighted"),
+ ("narrative", "emphasized"),
+}
+
+
+def has_token(text: str, token: str) -> bool:
+ return re.search(rf"(? 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 sources_block(intern: str) -> str:
+ parts = (intern or "").split("\nCURRENT_DAY_SOURCES\n")
+ if len(parts) < 2:
+ return intern or ""
+ rest = parts[-1]
+ for marker in ("\nEXISTING_TEXT\n", "\nAUSGABE\n"):
+ if marker in rest:
+ rest = rest.split(marker, 1)[0]
+ break
+ return rest
+
+
+def seed_item(slot: str, key: str) -> dict:
+ for item in (SEED.get("slots") or {}).get(slot, {}).get("variants") or []:
+ if item.get("guideline_key") == key:
+ return item
+ raise SystemExit(f"FAIL: missing seed {slot}/{key}")
+
+
+def seed_id(slot: str, key: str) -> str:
+ return seed_item(slot, key)["id"]
+
+
+def seed_instruction(slot: str, key: str) -> str:
+ return seed_item(slot, key).get("instruction") or ""
+
+
+def selection_of(**keys: str) -> dict[str, str]:
+ return {SLOT_TO_ID_KEY[slot]: seed_id(slot, key) for slot, key in keys.items()}
+
+
+def default_ids() -> dict[str, str]:
+ return selection_of(
+ transformation="substantial",
+ detail="complete",
+ voice="clear",
+ narrative="weighted",
+ )
+
+
+def low_ids() -> dict[str, str]:
+ return selection_of(
+ transformation="correction",
+ detail="compact",
+ voice="neutral",
+ narrative="chronicle",
+ )
+
+
+def high_ids() -> dict[str, str]:
+ return selection_of(
+ transformation="substantial",
+ detail="complete",
+ voice="clear",
+ narrative="emphasized",
+ )
+
+
+def selection_meta(payload: dict) -> dict:
+ return (payload.get("trace") or {}).get("generation_selection") or {}
+
+
+def test_runtime_has_no_numeric_policy() -> None:
+ files = [
+ ROOT / "journal_generation_policy.py",
+ ROOT / "journal_generate.py",
+ ROOT / "routers" / "journal.py",
+ ROOT / "routers" / "generation_instructions.py",
+ FRONTEND / "src" / "pages" / "JournalDayPage.jsx",
+ FRONTEND / "src" / "pages" / "AdminGenerationPage.jsx",
+ ]
+ for path in files:
+ text = path.read_text(encoding="utf-8")
+ for token in BANNED_RUNTIME_TOKENS:
+ if token == "generation_policy" and path.name == "journal_generation_policy.py":
+ continue
+ if token == "generation_policy" and path.name == "generation_instructions.py":
+ continue
+ expect(not has_token(text, token), f"{path.name} has no {token}")
+ day = (FRONTEND / "src" / "pages" / "JournalDayPage.jsx").read_text(encoding="utf-8")
+ expect('type="range"' not in day, "journal page has no sliders")
+ expect("policy-slider" not in day, "journal page has no slider class")
+ expect("SLOT_SELECTS" in day, "journal page names the four selects")
+ expect(day.count("slot:") >= 4, "journal page has four independent slots")
+ expect("
)}
+ {draftSummary && (
+ {draftSummary}
+ )}
+ setDesignOpen(event.currentTarget.open)}>
+ Gestaltung
+
+ {SLOT_SELECTS.map((item) => {
+ const options = generationOptions[item.slot] || []
+ const selected = options.find((option) => option.id === generationSelection[item.key])
+ return (
+
+ )
+ })}
+
+
Faktenregeln und Datenschutz bleiben unabhängig von diesen Einstellungen aktiv.
+
+
diff --git a/frontend/src/pages/JournalEditorPage.jsx b/frontend/src/pages/JournalEditorPage.jsx
index 09404c1..e943591 100644
--- a/frontend/src/pages/JournalEditorPage.jsx
+++ b/frontend/src/pages/JournalEditorPage.jsx
@@ -37,6 +37,7 @@ export default function JournalEditorPage() {
const [logOpen, setLogOpen] = useState(
Boolean((location.state?.run_log || []).length || location.state?.trace)
)
+ const [generationSummary, setGenerationSummary] = useState('')
const markClean = (nextTitle, nextBody) => {
const titleValue = entryTitle(nextTitle || '')
@@ -60,6 +61,7 @@ export default function JournalEditorPage() {
setVersions(entry.versions || [])
setMedia(entry.media || [])
setCurrentVersionId(entry.current_version_id || entry.versions?.at(-1)?.id || '')
+ setGenerationSummary('')
markClean(entry.title || '', entry.body || '')
return
}
@@ -71,6 +73,7 @@ export default function JournalEditorPage() {
setMedia([])
setSavedEntryId('')
setCurrentVersionId('')
+ setGenerationSummary(draft?.generation_summary || '')
markClean(draft?.title || '', draft?.body || '')
}
@@ -281,6 +284,7 @@ export default function JournalEditorPage() {
{error &&
{error}
}
{notice &&
{notice}
}
+ {generationSummary &&
{generationSummary}
}