"""Journal narration contract: facts stay, wording may change.""" from __future__ import annotations import json import os import sys import tempfile from pathlib import Path from unittest.mock import patch ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) 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 remember_mapping from journal_generate import unattested_journal_content from journal_shape import shape_journal from main import app from privacy_gateway import reset_debug from providers import ChatResult from writing_profile_store import ( NEUTRAL_JOURNAL_STYLE, compile_task_brief, get_profile, import_text, set_lifecycle, update_facet, ) def expect(ok: bool, message: str) -> None: if not ok: raise SystemExit(f"FAIL: {message}") print(f"OK {message}") def header(token: str) -> dict: return {"X-Auth-Token": token} def test_active_prompt_contract() -> None: init_db() prompt = load_active_prompt("mvp.journal_generate") text = prompt.get("template") or "" expect("INHALTSTREUE" in text, "active prompt keeps hard content rules") expect("CURRENT_DAY_SOURCES" in text, "active prompt labels current-day facts") expect("{{style_examples}}" in text, "active prompt still has the style-examples slot") expect("Rechtschreibung" in text or "korrigieren" in text, "active prompt allows spelling and grammar fixes") expect("Verneinungen" in text and "Unsicherheiten" in text, "active prompt keeps semantic uncertainty") expect("keine neuen tatsachen" in text.lower(), "active prompt still forbids new facts") expect("Plan und Vollzug" in text, "active prompt keeps plan versus completion") expect("[[…" not in text and "[[..." not in text, "active prompt must not teach ellipsis placeholders") 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("{{transformation_instructions}}" in text, "active prompt uses compiled transformation instructions") expect("{{source_mode_instructions}}" not in text, "active prompt has no source-mode placeholder") expect("Zwinge nicht den gesamten Tag in einen einzigen Quellenmodus." in text, "active prompt has mixed-source instruction") 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", "system prompt revision is stored") expect(row["template"] == row["default_template"], "untouched install uses the seeded template") def test_shape_keeps_rewritten_model_text() -> None: source = ["ich bin dan zum markt gegangen und da war es zimlich voll"] model = "Ich ging dann zum Markt, auf dem es ziemlich voll war." _, body = shape_journal("Markttag", model, source) expect(body == model, "shape_journal keeps the corrected model text") expect("zimlich" not in body, "source typo is not restored") def test_shape_does_not_restore_source_form() -> None: source = ["ich ging zum markt. danach der hafen."] _, punct = shape_journal("Tag", "Ich ging zum Markt. Danach der Hafen.", source) expect(punct == "Ich ging zum Markt. Danach der Hafen.", "corrected punctuation is not reset") _, casing = shape_journal("Tag", "Am Markt war es voll.", ["am markt war es voll"]) expect(casing == "Am Markt war es voll.", "corrected capitalization is not reset") rewritten = "Später am Hafen packte ich die rote Tasche." _, structure = shape_journal("Tag", rewritten, ["danach hafen. rote tasche eingepackt."]) expect(structure == rewritten, "changed sentence structure is not reset") fallback_parts = ["Zeile eins Markt.", "Zeile zwei Hafen und rote Tasche."] _, fallback = shape_journal("Ein Tag", "\n\n".join(fallback_parts), fallback_parts, source="fallback") expect("Markt" in fallback and "Hafen" in fallback and "rote Tasche" in fallback, "fallback keeps every unique user source") expect(fallback == "\n\n".join(fallback_parts) or "Markt" in fallback, "fallback path stays source-based") model_kept = "Ich war am Markt und später am Hafen." _, model = shape_journal("Tag", model_kept, fallback_parts, source="model") expect(model == model_kept, "model path is not mixed with the fallback join") def test_unattested_identity_is_journal_not_privacy() -> None: mappings = [ {"local_label": "Anna", "token": "PERSON:01"}, {"local_label": "Clarissa", "token": "PERSON:99"}, ] sources = ["Heute war ich mit Anna am Markt."] expect( unattested_journal_content("Ich ging mit Anna zum Markt.", sources, mappings, ["PERSON:01"]) is None, "attested person in rewritten prose is allowed", ) expect( unattested_journal_content("Clarissa kaufte Kirschen am Markt.", sources, mappings, ["PERSON:01"]) == "unattested_identity", "invented person as subject of a verb is still unattested", ) expect( unattested_journal_content("[[PERSON:99]] stand am Markt.", sources, mappings, ["PERSON:01"]) == "unattested_placeholder", "inactive placeholder is unattested content", ) expect( unattested_journal_content("Heute nur von Sushi essen erzählt.", sources, [{"local_label": "Sushi", "token": "PERSON:01"}], []) is None, "food homonym of an unused mapping is not unattested identity", ) expect( unattested_journal_content( "Clarissa kam ins Wohnzimmer.", ["Heute war Sushi im Wohnzimmer."], [ { "local_label": "Clarissa", "canonical_label": "Clarissa", "token": "PERSON:01", "aliases": ["Sushi"], "demask_label": "Clarissa", } ], ["PERSON:01"], ) is None, "confirmed alias in the source attests the canonical demasked spelling", ) def main() -> None: test_active_prompt_contract() test_shape_keeps_rewritten_model_text() test_shape_does_not_restore_source_form() test_unattested_identity_is_journal_not_privacy() expect(NEUTRAL_JOURNAL_STYLE.startswith("Neutraler Journalstil"), "neutral fallback exists") reset_debug() with TestClient(app) as client: setup = client.post( "/api/auth/setup", json={"email": "lars@example.test", "name": "Lars", "password": "test-pass"}, ) headers = header(setup.json()["token"]) profile_id = setup.json()["profile_id"] empty_brief = compile_task_brief(profile_id, "journal_generate") expect(empty_brief == NEUTRAL_JOURNAL_STYLE, "empty profile uses the neutral journal voice") space = client.post("/api/journal/spaces", headers=headers, json={"title": "Narration"}) day = client.post( f"/api/journal/spaces/{space.json()['id']}/days", headers=headers, json={"calendar_date": "2026-08-26"}, ) 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 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. ich bin dan zum markt gegangen."}, ) expect(turn.status_code == 200, f"turn {turn.status_code}") before = get_profile(profile_id) gen = client.post( f"/api/journal/days/{day.json()['day']['id']}/generate", headers=headers, json={"conversation_ids": [conv.json()["id"]]}, ) expect(gen.status_code == 200, f"generate {gen.text}") intern = "" for stage in (gen.json().get("trace") or {}).get("stages") or []: if stage.get("purpose") == "journal_generate": intern = stage.get("intern") or "" style = intern.split("\nCURRENT_DAY_SOURCES\n")[0] expect("Neutraler Journalstil" in style, "neutral style reaches the generate prompt") expect("Erzählmerkmale" not in style, "current day dialogue is not a style brief") expect("zimlich" not in style, "today's typo is not a style exemplar") expect("INHALTSTREUE" in intern, "runtime intern uses the new narration contract") expect("CURRENT_DAY_SOURCES" in intern, "runtime intern labels current-day facts") expect("Nur den verifizierten Nutzerwortlaut" not in intern, "old output-wording rule is not in the runtime prompt") expect("Unsicherheiten im Wortlaut" not in intern, "old uncertainty-in-wording rule is not in the runtime prompt") after = get_profile(profile_id) expect(after.get("version") == before.get("version"), "generate does not create a new profile version") expect((after.get("suggestions") or []) == (before.get("suggestions") or []), "generate does not add suggestions") import_text(profile_id, "Ich schreibe in kurzen, ruhigen Sätzen und lasse den Tag stehen.") from journal_editorial import select_journal_style_examples imported_examples = select_journal_style_examples(profile_id) expect( imported_examples and "kurzen, ruhigen Sätzen" in imported_examples[0]["excerpt"], "imported own text is a style source", ) imported_brief = compile_task_brief(profile_id, "journal_generate") expect(imported_brief == NEUTRAL_JOURNAL_STYLE, "unconfirmed profile does not become the brief") expect("zimlich" not in " ".join(item["excerpt"] for item in imported_examples), "today's dialogue is not mixed into the imported style") saved = client.post( "/api/journal/entries", headers=headers, json={ "journal_day_id": day.json()["day"]["id"], "title": "Eigene Fassung", "body": "Heute blieb ich beim klaren Schnitt und schrieb den Markt in eigenen Worten.", "origin": "user_edit", "source_conversation_ids": [conv.json()["id"]], }, ) expect(saved.status_code == 200, f"save {saved.text}") entry_examples = select_journal_style_examples(profile_id) expect( any("klaren Schnitt" in (item.get("excerpt") or "") for item in entry_examples), "final user-edited journal text is a style source", ) expect(all("zimlich" not in (item.get("excerpt") or "") for item in entry_examples), "current day dialogue is not a positive style reference") update_facet(profile_id, "core", value="Kurze Sätze, trockener Schnitt, keine Pathoswolken.") set_lifecycle(profile_id, "confirmed") with_core = compile_task_brief(profile_id, "journal_generate") expect("Core:" in with_core and "trockener Schnitt" in with_core, "confirmed core reaches the task brief") expect("Neutraler Journalstil" not in with_core, "confirmed profile replaces the neutral fallback") remember_mapping(profile_id, "Clarissa", "PERSON:99") def rewritten(_messages, _policy): return ChatResult( content=( "Markttag\n\n" "Ich ging dann zum Markt, auf dem es ziemlich voll war. " "Vielleicht bleibe ich kürzer. " "Brot holen wollte ich noch, habe es aber nicht gemacht. " "Die rote Tasche lag im Auto." ), model="fake", usage={}, context_compression="disabled", ) with patch("privacy_gateway.complete_model", rewritten): second = client.post( f"/api/journal/days/{day.json()['day']['id']}/generate", headers=headers, json={"conversation_ids": [conv.json()["id"]]}, ) expect(second.status_code == 200, f"rewritten generate {second.text}") body = second.json().get("body") or "" expect("ziemlich voll" in body, "corrected spelling of the model text is kept") expect("zimlich" not in body, "source typo is not restored after a real model rewrite") expect("Vielleicht bleibe ich kürzer" in body, "semantic uncertainty is kept") expect("wollte" in body.lower() and "nicht gemacht" in body, "plan versus non-fulfillment stays") expect("rote Tasche" in body, "unique attested detail is kept after rewrite") expect(body.lower().count("markt") <= 2, "repeated source lines are not pasted twice") expect( any(item.get("status") == "model" for item in (second.json().get("run_log") or [])), "accepted model path is recorded as model", ) intern2 = "" for stage in (second.json().get("trace") or {}).get("stages") or []: if stage.get("purpose") == "journal_generate": intern2 = stage.get("intern") or "" expect("trockener Schnitt" in intern2.split("\nCURRENT_DAY_SOURCES\n")[0], "confirmed writing profile reaches generate") def invent(_messages, _policy): return ChatResult( content="Clarissa blieb den ganzen Nachmittag am Hafen.", 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, f"unattested generate {blocked.text}") detail = blocked.json().get("detail") or {} expect(detail.get("code") == "journal_generation_not_accepted", "unattested person 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), "unattested person is a provenance reject") expect(any(item.get("status") == "not_accepted" for item in log), "reject path is distinct from the model path") expect("Clarissa" not in json.dumps((detail.get("diagnostics") or {}).get("log") or []), "clear labels stay out of compact logs") with get_db() as conn: conn.execute( "UPDATE ai_prompts SET template = 'CUSTOM JOURNAL PROMPT' WHERE slug = ?", ("mvp.journal_generate",), ) init_db() custom = load_active_prompt("mvp.journal_generate") expect(custom["template"] == "CUSTOM JOURNAL PROMPT", "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("INHALTSTREUE" in (row["default_template"] or ""), "default template still tracks the seed") expect(row["seed_revision"] == "2026-08-29-voice-legacy-immutable-v1", "revision updates even when template is custom") print("journal narration tests passed.") if __name__ == "__main__": main()