"""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)) from tests.harness import configure_test_engine configure_test_engine() os.environ["KANSHO_FAKE_PROVIDER"] = "1" os.environ["KANSHO_FAKE_DETECT"] = "1" 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, set_default_guideline, 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", "[[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"), ("voice", "with_examples"), ("voice", "legacy_neutral"), ("voice", "legacy_light"), ("voice", "legacy_noticeable"), ("voice", "legacy_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("Stilquellen" in day, "voice select can show which style sources apply") expect("type=\"range\"" not in day, "journal page has no extra sliders for style context") expect(day.count("= 4, "journal page has four independent slots") expect(" None: init_db() for rel in ("journal_generation_policy.py", "journal_editorial.py"): source = (ROOT / rel).read_text(encoding="utf-8") for phrase in BANNED_CODE_PHRASES: expect(phrase not in source, f"{rel} does not hardcode {phrase!r}") items = list_guidelines(PURPOSE_JOURNAL, include_instruction=True) got = {(item["slot"], item["guideline_key"]) for item in items} expect(REQUIRED_KEYS <= got, "seed creates every required guideline") expect("source_mode" not in (SEED.get("slots") or {}), "seed document has no source_mode slot") expect(sum(1 for item in items if item["slot"] == "source_mode") == 0, "source modes are not seeded") expect(all(item["status"] == "active" for item in items if item["is_system_seed"]), "seed rows start active") defaults = compile_selection(default_selection_ids()) expect(defaults.ids["transformation"] == seed_id("transformation", "substantial"), "default transformation is substantial") expect( defaults.instructions["transformation_instructions"] == seed_instruction("transformation", "substantial"), "default transformation instruction matches the seed", ) expect( defaults.instructions["detail_instructions"] == seed_instruction("detail", "complete"), "default detail instruction matches the seed", ) expect( defaults.instructions["voice_instructions"] == seed_instruction("voice", "clear"), "default voice instruction matches the seed", ) expect( defaults.instructions["narrative_instructions"] == seed_instruction("narrative", "weighted"), "default narrative instruction matches the seed", ) expect(defaults.seed_revision == SEED["seed_revision"], "compiled policy names the seed revision") expect(defaults.style_context["include_core"] is True, "default voice includes core") expect(defaults.style_context["include_facet"] is True, "default voice includes facet") expect(defaults.style_context["include_traits"] is True, "default voice includes traits") expect(defaults.style_context["include_style_examples"] is False, "default voice omits style examples") expect("source_mode_instructions" not in defaults.instructions, "compiler has no source-mode instruction") free = compile_selection({**default_selection_ids(), "voice_id": seed_id("voice", "neutral")}) expect(free.style_context == { "include_core": False, "include_facet": False, "include_traits": False, "include_style_examples": False, }, "profile-free voice selects no style sources") examples = compile_selection({**default_selection_ids(), "voice_id": seed_id("voice", "with_examples")}) expect(all(examples.style_context.values()), "with-examples voice selects all style sources") low = compile_selection(low_ids()) high = compile_selection(high_ids()) expect(low.keys["transformation"] == "correction", "low transformation is named") expect(high.keys["narrative"] == "emphasized", "high narrative is named") expect("source_mode_instructions" not in low.instructions, "low compile has no source mode") expect("source_mode_instructions" not in high.instructions, "high compile has no source mode") expect("{{" not in "".join(low.instructions.values()), "compiled instructions contain no placeholders") for bad in (None, [], "75", True, {"transformation_strength": 75}, {"transformation_id": seed_id("transformation", "substantial")}): try: validate_selection(bad) expect(False, "incomplete or numeric selection must be rejected") except GenerationPolicyError as exc: expect(exc.code == "invalid_generation_selection", "rejection uses invalid_generation_selection") draft = create_guideline( PURPOSE_JOURNAL, "transformation", { "guideline_key": "drafty", "label": "Entwurf", "summary": "nicht wählbar", "instruction": "Nur ein Draft.", }, ) try: validate_selection({**default_ids(), "transformation_id": draft["id"]}) expect(False, "drafts must not be selectable") except GenerationPolicyError: expect(True, "drafts are not selectable") cloned = clone_guideline(seed_id("transformation", "correction")) original = get_guideline(seed_id("transformation", "correction"), include_instruction=True) update_guideline( cloned["id"], { "guideline_key": original["guideline_key"], "label": original["label"], "summary": original["summary"], "instruction": "CHANGED CLONE TEXT", }, ) expect( get_guideline(original["id"], include_instruction=True)["instruction"] == original["instruction"], "cloning does not change the original", ) try: update_guideline(original["id"], {"label": "still active", "instruction": original["instruction"], "guideline_key": original["guideline_key"]}) expect(False, "published guidelines must be immutable") except CatalogError as exc: expect(exc.code == "guideline_immutable", "published guidelines are not silently overwritten") published_clone = publish_guideline(cloned["id"]) archived = archive_guideline(published_clone["id"]) expect(archived["status"] == "archived", "published clones can be archived") try: validate_selection({**default_ids(), "transformation_id": published_clone["id"]}) expect(False, "archived guidelines must not be selectable for new runs") except GenerationPolicyError: expect(True, "archived guidelines are not selectable for new runs") with get_db() as conn: seed_generation_instructions(conn) expect( get_guideline(original["id"], include_instruction=True)["instruction"] == original["instruction"], "seed does not overwrite published guidelines", ) 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 ( 'legacy-source-mode', ?, 'source_mode', 'prose_edit', 'Fließtext', '', 'LEGACY MODE TEXT', 0, 'active', 1, NULL, 0, 0, '', '', datetime('now'), datetime('now') ) """, (PURPOSE_JOURNAL,), ) seed_generation_instructions(conn) archived_legacy = conn.execute( "SELECT status FROM generation_guidelines WHERE id = 'legacy-source-mode'" ).fetchone() expect(archived_legacy["status"] == "archived", "persisted source modes are archived") expect("source_mode" not in overview_payload()["slots"], "admin overview hides archived source modes") def test_prompt_contract() -> None: init_db() prompt = load_active_prompt("mvp.journal_generate") text = prompt.get("template") or "" expect(len(text) < PREVIOUS_PROMPT_CHARS, "new prompt is shorter than the previous default") print(f"OK prompt length {len(text)} after, {PREVIOUS_PROMPT_CHARS} before") for key in ( "{{transformation_instructions}}", "{{detail_instructions}}", "{{voice_instructions}}", "{{narrative_instructions}}", "{{writing_profile}}", "{{style_examples}}", "{{reconstruction}}", "{{existing_text}}", ): expect(key in text, f"prompt contains {key}") expect("{{source_mode_instructions}}" not in text, "prompt has no source-mode placeholder") expect("{{editorial_mode}}" not in text, "prompt has no editorial_mode placeholder") expect("{{editorial_instructions}}" not in text, "prompt has no editorial_instructions placeholder") expect(MIXED_SOURCES_INSTRUCTION in text, "prompt contains the mixed-source instruction") expect("STYLE_EXAMPLES dienen ausschließlich" not in text, "standing prompt has no orphaned STYLE_EXAMPLES sentence") for fact in HARD_FACTS: expect(fact in text, f"hard fact rule remains: {fact}") expect("ich gieng zum laden" not in text, "no synthetic prose example") expect("nachbarhund im garten" not in text, "no synthetic notes example") expect("EDITORIAL_MODE" not in text, "source mode is not a user-facing prompt heading") expect(GENERATE_SEED_REVISION == "2026-08-29-voice-legacy-immutable-v1", "seed revision constant tracks immutable voice catalog") with get_db() as conn: row = conn.execute( "SELECT seed_revision, template, default_template FROM ai_prompts WHERE slug = ?", ("mvp.journal_generate",), ).fetchone() expect(row["seed_revision"] == "2026-08-29-voice-legacy-immutable-v1", "untouched default is updated to immutable voice catalog") expect(row["template"] == row["default_template"], "untouched template matches the seed") expect("source_mode" not in (SEED.get("slots") or {}), "source modes stay out of the seed") def test_unattested_alias() -> None: mappings = [ { "local_label": "Clarissa", "canonical_label": "Clarissa", "token": "PERSON:01", "aliases": ["Sushi"], "demask_label": "Clarissa", } ] expect( unattested_journal_content( "Clarissa kam ins Wohnzimmer.", ["Heute war Sushi im Wohnzimmer."], mappings, ["PERSON:01"], ) is None, "canonical demask of an attested alias is not unattested_identity", ) expect( unattested_journal_content( "Hanna kam ins Wohnzimmer.", ["Heute war Sushi im Wohnzimmer."], mappings + [{"local_label": "Hanna", "token": "PERSON:99"}], ["PERSON:01"], ) == "unattested_identity", "a different person remains unattested", ) def main() -> None: test_runtime_has_no_numeric_policy() test_seed_and_compiler() test_prompt_contract() test_unattested_alias() reset_debug() with TestClient(app) as client: setup = client.post( "/api/auth/setup", json={"email": "ada@example.test", "name": "Ada", "password": "test-pass"}, ) headers = header(setup.json()["token"]) profile_id = setup.json()["profile_id"] settings = client.get("/api/journal/generation-settings", headers=headers) expect(settings.status_code == 200, f"generation-settings {settings.text}") body = settings.json() expect(body["selection"] == default_ids(), "profile defaults are the named system standards") expect(set(body["selection"]) == set(SELECTION_KEYS), "settings expose the four guideline ids") for slot, options in body["options"].items(): expect(options, f"{slot} has active options") expect(all("instruction" not in item for item in options), f"{slot} options hide prompt text") expect(all(item["id"] != "draft" for item in options), f"{slot} options are real ids") stored = get_or_create_selection(profile_id) expect({key: stored[key] for key in SELECTION_KEYS} == default_ids(), "persisted defaults match system standards") with get_db() as conn: conn.execute( "INSERT INTO profiles (id, email, name, password_hash, role) VALUES (?, ?, ?, ?, 'user')", ("other-profile", "other@example.test", "Other", "x"), ) save_selection("other-profile", high_ids()) save_selection(profile_id, low_ids()) expect(load_selection(profile_id)["transformation_id"] == seed_id("transformation", "correction"), "profile A keeps its own selection") expect(load_selection("other-profile")["transformation_id"] == seed_id("transformation", "substantial"), "profile B is isolated") save_selection(profile_id, default_ids()) space = client.post("/api/journal/spaces", headers=headers, json={"title": "Policy"}) day = client.post( f"/api/journal/spaces/{space.json()['id']}/days", headers=headers, json={"calendar_date": "2026-08-27"}, ) conv = client.post( f"/api/journal/days/{day.json()['day']['id']}/conversations", headers=headers, json={"title": "Tag"}, ) turn = client.post( f"/api/journal/conversations/{conv.json()['id']}/turn", headers=headers, json={"body": "Ich war am Markt. Vielleicht bleibe ich kürzer. Brot holen wollte ich noch, habe es aber nicht gemacht."}, ) expect(turn.status_code == 200, f"turn {turn.status_code}") rejected = client.post( f"/api/journal/days/{day.json()['day']['id']}/generate", headers=headers, json={"conversation_ids": [conv.json()["id"]], "generation_selection": {"transformation_id": "missing"}}, ) expect(rejected.status_code == 400, "unknown selection is rejected") expect((rejected.json().get("detail") or {}).get("code") == "invalid_generation_selection", "rejection uses the selection code") after_reject = client.get("/api/journal/generation-settings", headers=headers).json() expect(after_reject["selection"] == default_ids(), "rejected values are not stored") save_selection(profile_id, low_ids()) set_default_guideline(seed_id("transformation", "substantial")) expect(load_selection(profile_id)["transformation_id"] == seed_id("transformation", "correction"), "setting a catalog default does not rewrite a stored selection") save_selection(profile_id, default_ids()) blocked_calls = {"n": 0} def no_provider(*_a, **_k): blocked_calls["n"] += 1 raise AssertionError("invalid selection must not call a provider") with patch("privacy_gateway.complete_model", no_provider), patch("entity_detect.complete_chat", no_provider): before_provider = client.post( f"/api/journal/days/{day.json()['day']['id']}/generate", headers=headers, json={ "conversation_ids": [conv.json()["id"]], "generation_selection": {**default_ids(), "transformation_id": "missing"}, }, ) expect(before_provider.status_code == 400, "invalid selection still fails closed") expect(blocked_calls["n"] == 0, "invalid selection aborts before detect and generate") draft = client.post( "/api/admin/generation-instructions/journal_generate", headers=headers, json={ "slot": "voice", "guideline_key": "hidden_draft", "label": "versteckt", "summary": "Draft", "instruction": "Nicht für neue Läufe.", }, ) expect(draft.status_code == 200, f"admin draft {draft.text}") blocked_draft = client.post( f"/api/journal/days/{day.json()['day']['id']}/generate", headers=headers, json={ "conversation_ids": [conv.json()["id"]], "generation_selection": {**default_ids(), "voice_id": draft.json()["id"]}, }, ) expect(blocked_draft.status_code == 400, "drafts are rejected for new runs") reset_debug() recorder = install_test_recorder() snapshot_run = client.post( f"/api/journal/days/{day.json()['day']['id']}/generate", headers=headers, json={ "conversation_ids": [conv.json()["id"]], "generation_selection": low_ids(), "remember_generation_selection": False, }, ) expect(snapshot_run.status_code == 200, f"low generate {snapshot_run.text}") intern = intern_of(snapshot_run.json()) meta = selection_meta(snapshot_run.json()) expect(meta.get("source") == "request", "trace names the request snapshot") expect(meta.get("remembered") is False, "unremembered snapshot is marked") expect(meta.get("ids") == { "transformation": seed_id("transformation", "correction"), "detail": seed_id("detail", "compact"), "voice": seed_id("voice", "neutral"), "narrative": seed_id("narrative", "chronicle"), }, "trace keeps the used ids") expect(meta.get("keys", {}).get("transformation") == "correction", "trace keeps guideline keys") expect(meta.get("revisions", {}).get("transformation") == 1, "trace keeps revisions") expect("editorial_mode" not in meta, "trace has no abandoned editorial mode") expect("source_mode" not in meta, "trace has no abandoned source mode") expect("Korrigiere nur" not in str(meta), "trace does not store compiled instruction text") expect( client.get("/api/journal/generation-settings", headers=headers).json()["selection"] == default_ids(), "unremembered snapshot does not change profile defaults", ) snapshot = snapshot_run.json().get("generation_snapshot") or {} expect(snapshot.get("transformation", {}).get("key") == "correction", "draft stores transformation key") expect(snapshot.get("prompt_slug") == "mvp.journal_generate", "draft stores prompt slug") expect("instruction" not in str(snapshot), "draft snapshot omits instruction text") expect("source_mode" not in snapshot, "draft snapshot has no source_mode") expect("editorial_mode" not in snapshot, "draft snapshot has no editorial_mode") summary = snapshot_run.json().get("generation_summary") or "" expect(summary.startswith("Erzeugt mit:"), "draft summary is user-readable") expect("Quellenmodus:" not in summary, "draft summary does not name a source mode") expect("{{transformation_instructions}}" not in intern, "transformation placeholder is resolved") expect("{{detail_instructions}}" not in intern, "detail placeholder is resolved") expect("{{voice_instructions}}" not in intern, "voice placeholder is resolved") expect("{{narrative_instructions}}" not in intern, "narrative placeholder is resolved") expect("{{source_mode_instructions}}" not in intern, "source-mode placeholder is absent") expect(MIXED_SOURCES_INSTRUCTION in intern, "mixed-source instruction reaches the prompt") expect(seed_instruction("transformation", "correction") in intern, "selected transformation reaches the prompt") expect(seed_instruction("detail", "compact") in intern, "selected detail reaches the prompt") expect(seed_instruction("voice", "neutral") in intern, "selected voice reaches the prompt") expect(seed_instruction("narrative", "chronicle") in intern, "selected narrative reaches the prompt") expect([item.get("purpose") for item in recorder] == ["journal_generate"], "exactly one generate call") expect(sum(1 for item in (snapshot_run.json().get("run_log") or []) if item.get("kind") == "model_call") == 1, "run log records one model call") expect( not any("Korrigiere nur Rechtschreibung" in str(item) for item in (snapshot_run.json().get("run_log") or [])), "run log omits compiled prompt instructions", ) for fact in HARD_FACTS: expect(fact in intern, f"hard fact remains at low policy: {fact}") copyedit_ids = selection_of( transformation="copyedit", detail="complete", voice="clear", narrative="weighted", ) reset_debug() copyedit_recorder = install_test_recorder() copyedit_run = client.post( f"/api/journal/days/{day.json()['day']['id']}/generate", headers=headers, json={ "conversation_ids": [conv.json()["id"]], "generation_selection": copyedit_ids, "remember_generation_selection": False, }, ) expect(copyedit_run.status_code == 200, f"copyedit generate {copyedit_run.text}") copyedit_meta = selection_meta(copyedit_run.json()) copyedit_intern = intern_of(copyedit_run.json()) copyedit_snap = copyedit_run.json().get("generation_snapshot") or {} expect(copyedit_meta.get("ids") == { "transformation": seed_id("transformation", "copyedit"), "detail": seed_id("detail", "complete"), "voice": seed_id("voice", "clear"), "narrative": seed_id("narrative", "weighted"), }, "copyedit request ids reach the trace") expect(copyedit_meta.get("keys", {}).get("transformation") == "copyedit", "copyedit key reaches the trace") expect(copyedit_meta.get("revisions", {}).get("transformation") == 1, "copyedit revision reaches the trace") expect(copyedit_meta.get("revisions", {}).get("narrative") == 1, "weighted revision of the selected id reaches the trace") expect(copyedit_snap.get("transformation", {}).get("id") == seed_id("transformation", "copyedit"), "snapshot stores the selected copyedit id") expect(copyedit_snap.get("transformation", {}).get("revision") == 1, "snapshot stores the selected copyedit revision") expect(copyedit_snap.get("narrative", {}).get("id") == seed_id("narrative", "weighted"), "snapshot stores the selected weighted id") expect(seed_instruction("transformation", "copyedit") in copyedit_intern, "selected copyedit instruction reaches the prompt") expect(seed_instruction("narrative", "weighted") in copyedit_intern, "selected weighted instruction reaches the prompt") expect(seed_instruction("transformation", "substantial") not in copyedit_intern, "unselected substantial instruction is absent") expect([item.get("purpose") for item in copyedit_recorder] == ["journal_generate"], "copyedit still uses one generate call") preview = client.post( "/api/admin/generation-instructions/journal_generate/preview", headers=headers, json={"generation_selection": copyedit_ids}, ) expect(preview.status_code == 200, f"copyedit preview {preview.text}") expect(preview.json()["transformation_instructions"] == seed_instruction("transformation", "copyedit"), "preview compiles the same copyedit instruction") expect(preview.json().get("selection", {}).get("ids", {}).get("transformation") == seed_id("transformation", "copyedit"), "preview reports the same copyedit id") expect(preview.json().get("selection", {}).get("revisions", {}).get("narrative") == 1, "preview reports the same weighted revision") notes_day = client.post( f"/api/journal/spaces/{space.json()['id']}/days", headers=headers, json={"calendar_date": "2026-08-28"}, ) notes_conv = client.post( f"/api/journal/days/{notes_day.json()['day']['id']}/conversations", headers=headers, json={"title": "Notizen"}, ) client.post( f"/api/journal/conversations/{notes_conv.json()['id']}/turn", headers=headers, json={"body": "- Markt\n- Brot nicht geholt\n- vielleicht kürzer bleiben"}, ) notes_run = client.post( f"/api/journal/days/{notes_day.json()['day']['id']}/generate", headers=headers, json={"conversation_ids": [notes_conv.json()["id"]], "generation_selection": default_ids()}, ) expect(notes_run.status_code == 200, f"notes generate {notes_run.text}") notes_intern = intern_of(notes_run.json()) notes_snap = notes_run.json().get("generation_snapshot") or {} expect("source_mode" not in notes_snap, "notes snapshot has no source mode") expect("editorial_mode" not in (notes_run.json().get("trace") or {}), "notes trace has no editorial mode") expect(MIXED_SOURCES_INSTRUCTION in notes_intern, "notes use the unified mixed-source instruction") expect("Markt" in sources_block(notes_intern), "notes sources remain complete") def generate_shapes(date: str, bodies: list[str], label: str) -> None: shaped_day = client.post( f"/api/journal/spaces/{space.json()['id']}/days", headers=headers, json={"calendar_date": date}, ) shaped_conv = client.post( f"/api/journal/days/{shaped_day.json()['day']['id']}/conversations", headers=headers, json={"title": label}, ) for body in bodies: client.post( f"/api/journal/conversations/{shaped_conv.json()['id']}/turn", headers=headers, json={"body": body}, ) reset_debug() rec = install_test_recorder() run = client.post( f"/api/journal/days/{shaped_day.json()['day']['id']}/generate", headers=headers, json={"conversation_ids": [shaped_conv.json()["id"]], "generation_selection": default_ids()}, ) expect(run.status_code == 200, f"{label} generate {run.text}") shaped_intern = intern_of(run.json()) shaped_sources = sources_block(shaped_intern) shaped_snap = run.json().get("generation_snapshot") or {} expect(MIXED_SOURCES_INSTRUCTION in shaped_intern, f"{label} has mixed-source instruction") expect("{{source_mode_instructions}}" not in shaped_intern, f"{label} has no source-mode placeholder") expect("source_mode" not in shaped_snap, f"{label} snapshot has no source_mode") expect("editorial_mode" not in shaped_snap, f"{label} snapshot has no editorial_mode") expect("editorial_mode" not in (run.json().get("trace") or {}), f"{label} trace has no editorial_mode") expect([item.get("purpose") for item in rec] == ["journal_generate"], f"{label} is exactly one generate call") for body in bodies: for line in body.splitlines(): token = line.lstrip("- ").strip() if token: expect(token in shaped_sources, f"{label} keeps source {token!r}") generate_shapes("2026-08-11", ["Ich ging zum Markt. Es war voll. Vielleicht bleibe ich kürzer."], "prose-only") generate_shapes("2026-08-12", ["- Kirschen\n- Brot nicht geholt\n- später Hafen"], "notes-only") generate_shapes( "2026-08-13", ["Ich ging zum Markt. Es war voll.", "- Kirschen\n- später Hafen"], "prose-then-notes", ) generate_shapes( "2026-08-14", ["- Kirschen\n- später Hafen", "Ich ging zum Markt. Es war voll."], "notes-then-prose", ) generate_shapes( "2026-08-15", ["Ich ging zum Markt. Es war voll.\n- Kirschen\n- später Hafen"], "mixed-in-message", ) high_run = client.post( f"/api/journal/days/{day.json()['day']['id']}/generate", headers=headers, json={ "conversation_ids": [conv.json()["id"]], "generation_selection": high_ids(), "remember_generation_selection": True, }, ) expect(high_run.status_code == 200, f"high generate {high_run.text}") high_intern = intern_of(high_run.json()) expect("Überarbeite den Text substanziell" in high_intern, "high transformation reaches the prompt") expect("Korrigiere nur Rechtschreibung" not in high_intern, "low and high prompt blocks differ") expect(intern != high_intern, "low and high snapshots render different prompt blocks") expect(selection_meta(high_run.json()).get("remembered") is True, "remembered snapshot is marked") remembered = client.get("/api/journal/generation-settings", headers=headers).json() expect(remembered["selection"] == high_ids(), "remembered snapshot becomes the profile default") for fact in HARD_FACTS: expect(fact in high_intern, f"hard fact remains at high policy: {fact}") profile_run = client.post( f"/api/journal/days/{day.json()['day']['id']}/generate", headers=headers, json={"conversation_ids": [conv.json()["id"]]}, ) expect(profile_run.status_code == 200, f"profile generate {profile_run.text}") expect(selection_meta(profile_run.json()).get("source") == "profile", "omitted snapshot uses stored values") expect(seed_instruction("narrative", "emphasized") in intern_of(profile_run.json()), "stored high narrative is reused") confirm_identity(profile_id, "Clarissa", aliases=["Sushi"]) alias_day = client.post( f"/api/journal/spaces/{space.json()['id']}/days", headers=headers, json={"calendar_date": "2026-08-21"}, ) alias_conv = client.post( f"/api/journal/days/{alias_day.json()['day']['id']}/conversations", headers=headers, json={"title": "Alias"}, ) client.post( f"/api/journal/conversations/{alias_conv.json()['id']}/turn", headers=headers, json={"body": "Sushi kam ins Wohnzimmer."}, ) def canonical_reply(_messages, _policy): return ChatResult( content="Wohnzimmer\n\nClarissa kam ins Wohnzimmer.", model="fake", usage={}, context_compression="disabled", ) with patch("privacy_gateway.complete_model", canonical_reply): alias_out = client.post( f"/api/journal/days/{alias_day.json()['day']['id']}/generate", headers=headers, json={"conversation_ids": [alias_conv.json()["id"]]}, ) expect(alias_out.status_code == 200, f"confirmed alias generate {alias_out.text}") expect("Clarissa" in (alias_out.json().get("body") or ""), "canonical spelling is kept after demask") expect((alias_out.json().get("trace") or {}).get("model_text_accepted") is True, "alias demask is not rejected as unattested") remember_mapping(profile_id, "Hanna", "PERSON:99") def invent(_messages, _policy): return ChatResult( content="Hanna am Hafen\n\nIch war am Markt.", model="fake", usage={}, context_compression="disabled", ) with patch("privacy_gateway.complete_model", invent): blocked = client.post( f"/api/journal/days/{day.json()['day']['id']}/generate", headers=headers, json={"conversation_ids": [conv.json()["id"]]}, ) expect(blocked.status_code == 409, "unattested person is still rejected") expect((blocked.json().get("detail") or {}).get("code") == "journal_generation_not_accepted", "409 provenance rule remains") created_user = client.post( "/api/users", headers=headers, json={"email": "policy-user@example.test", "name": "User", "password": "user-pass", "role": "user"}, ) expect(created_user.status_code == 200, f"create user {created_user.text}") user_login = client.post( "/api/auth/login", json={"email": "policy-user@example.test", "password": "user-pass"}, ) user_headers = header(user_login.json()["token"]) denied = client.get("/api/admin/generation-instructions/journal_generate", headers=user_headers) expect(denied.status_code == 403, "admin catalog is protected") denied_put = client.put( "/api/admin/generation-instructions/journal_generate/" + seed_id("transformation", "substantial"), headers=user_headers, json={"label": "nein"}, ) expect(denied_put.status_code == 403, "admin catalog write is protected") catalog = client.get("/api/admin/generation-instructions/journal_generate", headers=headers) expect(catalog.status_code == 200, f"admin catalog {catalog.text}") expect("source_mode" not in catalog.json()["slots"], "admin catalog has no source-mode slot") expect("instruction" not in str(catalog.json()["slots"]["transformation"]), "admin overview omits prompt bodies") cloned = client.post( f"/api/admin/generation-instructions/journal_generate/{seed_id('transformation', 'substantial')}/clone", headers=headers, ) expect(cloned.status_code == 200, f"clone {cloned.text}") saved = client.put( f"/api/admin/generation-instructions/journal_generate/{cloned.json()['id']}", headers=headers, json={ "guideline_key": "substantial", "label": "substanziell", "summary": "Klon", "instruction": "ADMIN_CUSTOM_TRANSFORMATION_BLOCK", }, ) expect(saved.status_code == 200, f"admin draft save {saved.text}") published = client.post( f"/api/admin/generation-instructions/journal_generate/{cloned.json()['id']}/publish", headers=headers, ) expect(published.status_code == 200, f"publish {published.text}") expect(load_selection(profile_id)["transformation_id"] == seed_id("transformation", "substantial"), "publishing a clone does not rewrite the stored selection") immutable = client.put( f"/api/admin/generation-instructions/journal_generate/{seed_id('transformation', 'substantial')}", headers=headers, json={"label": "überschreiben", "instruction": "should not stick", "guideline_key": "substantial"}, ) expect(immutable.status_code == 409, "active guidelines cannot be overwritten") preview = client.post( "/api/admin/generation-instructions/journal_generate/preview", headers=headers, json={ "generation_selection": {**high_ids(), "transformation_id": cloned.json()["id"]}, }, ) expect(preview.status_code == 200, f"admin preview {preview.text}") expect(preview.json()["transformation_instructions"] == "ADMIN_CUSTOM_TRANSFORMATION_BLOCK", "preview compiles locally") expect("(nicht enthalten)" in (preview.json().get("rendered") or ""), "preview has no personal sources") expect("{{transformation_instructions}}" not in (preview.json().get("rendered") or ""), "preview resolves placeholders") custom_run = client.post( f"/api/journal/days/{day.json()['day']['id']}/generate", headers=headers, json={ "conversation_ids": [conv.json()["id"]], "generation_selection": {**high_ids(), "transformation_id": cloned.json()["id"]}, "remember_generation_selection": False, }, ) expect(custom_run.status_code == 200, f"custom generate {custom_run.text}") expect("ADMIN_CUSTOM_TRANSFORMATION_BLOCK" in intern_of(custom_run.json()), "admin change reaches the next rendered prompt") init_db() after_seed = overview_payload() substantial = next( item for item in after_seed["slots"]["transformation"] if item["id"] == seed_id("transformation", "substantial") ) expect(get_guideline(substantial["id"], include_instruction=True)["instruction"] == seed_instruction("transformation", "substantial"), "seed does not overwrite the original") expect(get_guideline(cloned.json()["id"], include_instruction=True)["instruction"] == "ADMIN_CUSTOM_TRANSFORMATION_BLOCK", "seed does not overwrite admin clones") restored = client.post( "/api/admin/generation-instructions/journal_generate/reset", headers=headers, ) expect(restored.status_code == 200, f"reset {restored.text}") drafts = [item for item in restored.json()["slots"]["transformation"] if item["status"] == "draft"] expect(drafts, "reset adds seed drafts") expect( get_guideline(cloned.json()["id"], include_instruction=True)["instruction"] == "ADMIN_CUSTOM_TRANSFORMATION_BLOCK", "reset does not overwrite historical variants", ) archived = client.post( f"/api/admin/generation-instructions/journal_generate/{cloned.json()['id']}/archive", headers=headers, ) expect(archived.status_code == 200, f"archive {archived.text}") archived_run = client.post( f"/api/journal/days/{day.json()['day']['id']}/generate", headers=headers, json={ "conversation_ids": [conv.json()["id"]], "generation_selection": {**high_ids(), "transformation_id": cloned.json()["id"]}, }, ) expect(archived_run.status_code == 400, "archived guidelines are rejected for new runs") fresh_day = client.post( f"/api/journal/spaces/{space.json()['id']}/days", headers=headers, json={"calendar_date": "2026-08-16"}, ) fresh_conv = client.post( f"/api/journal/days/{fresh_day.json()['day']['id']}/conversations", headers=headers, json={"title": "Legacy"}, ) client.post( f"/api/journal/conversations/{fresh_conv.json()['id']}/turn", headers=headers, json={"body": "Ich war am Markt."}, ) with get_db() as conn: current_template = conn.execute( "SELECT template FROM ai_prompts WHERE slug = ?", ("mvp.journal_generate",), ).fetchone()["template"] conn.execute( "UPDATE ai_prompts SET template = ? WHERE slug = ?", ((current_template or "") + "\n{{source_mode_instructions}}\n", "mvp.journal_generate"), ) reset_debug() legacy_recorder = install_test_recorder() legacy = client.post( f"/api/journal/days/{fresh_day.json()['day']['id']}/generate", headers=headers, json={"conversation_ids": [fresh_conv.json()["id"]], "generation_selection": default_ids()}, ) expect(legacy.status_code == 409, "legacy custom prompt is refused before the provider") legacy_detail = legacy.json().get("detail") or {} expect(legacy_detail.get("code") == "prompt_contract_incompatible", "legacy prompt uses prompt_contract_incompatible") expect("veralteten Platzhalter" in (legacy_detail.get("message") or ""), "admin hint names the retired placeholder") expect(legacy_recorder == [], "legacy custom prompt does not call the provider") expect(current_draft(profile_id, fresh_day.json()["day"]["id"]) is None, "legacy custom prompt inserts no raw draft") with get_db() as conn: conn.execute( "UPDATE ai_prompts SET template = default_template WHERE slug = ?", ("mvp.journal_generate",), ) with get_db() as conn: conn.execute( """ UPDATE generation_guidelines SET instruction = '' WHERE id = ? """, (seed_id("transformation", "substantial"),), ) reset_debug() broken_recorder = install_test_recorder() broken = client.post( f"/api/journal/days/{day.json()['day']['id']}/generate", headers=headers, json={"conversation_ids": [conv.json()["id"]], "generation_selection": default_ids()}, ) expect(broken.status_code == 409, "invalid catalog refuses generate") expect((broken.json().get("detail") or {}).get("code") == "generation_policy_invalid", "invalid catalog uses generation_policy_invalid") expect(broken_recorder == [], "invalid catalog does not call the provider") with get_db() as conn: conn.execute( """ UPDATE generation_guidelines SET instruction = ? WHERE id = ? """, (seed_instruction("transformation", "substantial"), seed_id("transformation", "substantial")), ) with get_db() as conn: conn.execute( "UPDATE ai_prompts SET template = 'CUSTOM POLICY PROMPT {{reconstruction}}' WHERE slug = ?", ("mvp.journal_generate",), ) init_db() custom = load_active_prompt("mvp.journal_generate") expect(custom["template"] == "CUSTOM POLICY PROMPT {{reconstruction}}", "independently edited prompt is not overwritten") with get_db() as conn: row = conn.execute( "SELECT default_template, seed_revision FROM ai_prompts WHERE slug = ?", ("mvp.journal_generate",), ).fetchone() expect("{{transformation_instructions}}" in (row["default_template"] or ""), "default template still tracks mixed sources") expect(MIXED_SOURCES_INSTRUCTION in (row["default_template"] or ""), "default template has mixed-source instruction") expect(row["seed_revision"] == "2026-08-29-voice-legacy-immutable-v1", "revision updates even when template is custom") readable = snapshot_summary( { "transformation": {"label": "spürbar"}, "detail": {"label": "weitgehend"}, "voice": {"label": "deutlich"}, "narrative": {"label": "gewichtet"}, } ) expect("Erzeugt mit:\nSpürbar · Weitgehend · Deutlich · Gewichtet" in readable, "snapshot summary is readable") expect("Quellenmodus" not in readable, "snapshot summary does not name a source mode") print("journal generation policy tests passed.") if __name__ == "__main__": main()