"""Deterministic journal editorial contract. Fake provider proves data flow, not live prose.""" from __future__ import annotations 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_DB_PATH"] = str(Path(tempfile.gettempdir()) / "kansho-journal-editorial-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 remember_mapping from journal_editorial import ( GENERATE_SEED_REVISION, format_style_examples, incomplete_syntax_markers, lexical_similarity, narration_sources_text, select_journal_style_examples, ) from journal_generate import pack_narration_context, unattested_journal_content from main import app from privacy_gateway import reset_debug from prompt_budget import JournalBudget from providers import ChatResult from writing_profile_store import ( NEUTRAL_JOURNAL_STYLE, compile_task_brief, import_text, set_lifecycle, update_facet, ) 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: if not ok: raise SystemExit(f"FAIL: {message}") print(f"OK {message}") def header(token: str) -> dict: return {"X-Auth-Token": token} 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("INHALTSTREUE" in text, "prompt keeps hard content rules") expect("CURRENT_DAY_SOURCES" in text, "prompt labels current-day facts") expect("STYLE_EXAMPLES" in text, "prompt labels style examples") expect("WRITING_PROFILE" in text, "prompt labels the writing profile") expect("{{transformation_instructions}}" in text, "prompt injects compiled transformation instructions") expect("{{detail_instructions}}" in text, "prompt injects compiled detail instructions") 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("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 = ?", ("mvp.journal_generate",), ).fetchone() expect(row["seed_revision"] == SEED_REVISION, "system prompt revision is stored") expect(row["template"] == row["default_template"], "untouched install uses the seeded template") def test_sources_and_examples_are_separated() -> None: artifact = { "kind": "verified_artifact", "coverage": "all_selected_sources", "sources": [{"source_id": "u1", "role": "user", "text": "Heute nur der Markt."}], } presented = narration_sources_text(artifact) expect("[u1]" in presented and "Heute nur der Markt." in presented, "day facts are labeled source blocks") expect("kind" not in presented, "narration does not dump artifact JSON") formatted = format_style_examples( [{"kind": "journal_entry", "excerpt": "Ruhige Sätze, trockener Schnitt.", "occurred_at": "2026-07-01"}] ) expect("nicht übernommen" in formatted, "style examples forbid copying their facts") expect("Ruhige Sätze" in formatted, "selected example text is present") def test_unattested_covers_title_and_body() -> None: mappings = [{"local_label": "Maren", "token": "PERSON:01"}, {"local_label": "Hanna", "token": "PERSON:99"}] sources = ["Heute war ich mit Maren am Markt."] expect( unattested_journal_content("Markttag\n\nIch ging mit Maren zum Markt.", sources, mappings, ["PERSON:01"]) is None, "attested person in title and body is allowed", ) expect( unattested_journal_content("Hanna am Hafen\n\nIch ging zum Markt.", sources, mappings, ["PERSON:01"]) == "unattested_identity", "unattested person in the title is rejected", ) expect( unattested_journal_content("[[PERSON:99]]\n\nIch ging zum Markt.", sources, mappings, ["PERSON:01"]) == "unattested_placeholder", "inactive placeholder in the title is unattested", ) expect( unattested_journal_content("[[PERSON:01]] am Markt\n\n[[PERSON:01]] kaufte Kirschen.", sources, mappings, ["PERSON:01"]) is None, "the same attested placeholder may appear in title and body", ) expect( unattested_journal_content("Um 6:30 Uhr kam [[...]] ins Wohnzimmer.", sources, mappings, ["PERSON:01"]) == "unattested_placeholder", "ellipsis placeholder leftover is unattested", ) expect( unattested_journal_content("Um 6:30 Uhr kam [[…]] ins Wohnzimmer.", sources, mappings, ["PERSON:01"]) == "unattested_placeholder", "unicode ellipsis placeholder leftover is unattested", ) def test_budget_pack_drops_examples_first() -> None: init_db() prompt = { "id": "eval-pack", "slug": "mvp.journal_generate", "prompt_type": "base", "template": "PROFILE\n{{writing_profile}}\nEX\n{{style_examples}}\nDAY\n{{reconstruction}}\nOLD\n{{existing_text}}\n", } assembled = { "writing_profile": "Core: kurze Sätze.", "reconstruction": "Heute Markt.", "style_examples": "", "existing_text": "", } huge_examples = "Stil " + ("Beispielwort " * 400) existing = "Bestehende Fassung " + ("alt " * 40) budget = JournalBudget( model="fake", purpose="journal_generate", effective_context_window=32_768, reserved_output_tokens=256, safety_margin=0.15, available_input_tokens=estimate_cap(assembled, prompt, extra=80), chars_per_token=2.0, ) packed, dropped = pack_narration_context( prompt, budget, assembled, style_examples=huge_examples, existing_text=existing, include_existing=True, ) expect("style_examples" in dropped, "style examples are dropped before day sources") expect("Heute Markt." in packed["reconstruction"], "day sources stay") expect("kurze Sätze" in packed["writing_profile"], "writing profile stays") def estimate_cap(assembled: dict, prompt: dict, extra: int) -> int: from engine import preview_prompt from prompt_budget import estimate_tokens base = dict(assembled) rendered = preview_prompt(prompt, base)["rendered"] return estimate_tokens(rendered) + extra 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 main() -> None: test_syntax_diagnostics() test_prompt_contract() test_sources_and_examples_are_separated() test_unattested_covers_title_and_body() test_budget_pack_drops_examples_first() expect(lexical_similarity("a b c", "a b c") > 0.9, "similarity helper is diagnostic") 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": "ada@example.test", "name": "Ada", "password": "test-pass"}, ) headers = header(setup.json()["token"]) profile_id = setup.json()["profile_id"] expect(compile_task_brief(profile_id) == NEUTRAL_JOURNAL_STYLE, "unconfirmed profile is not a style authority") space = client.post("/api/journal/spaces", headers=headers, json={"title": "Editorial"}) 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"}, ) prose_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." ) turn = client.post( f"/api/journal/conversations/{conv.json()['id']}/turn", headers=headers, json={"body": prose_body}, ) expect(turn.status_code == 200, f"turn {turn.status_code}") 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.status_code}") intern = intern_of(gen.json()) expect(MIXED_SOURCES_INSTRUCTION in intern, "unified mixed-source instruction reaches the model") expect("CURRENT_DAY_SOURCES" in intern, "day facts are labeled") expect("STYLE_EXAMPLES" in intern, "style examples are labeled") expect("WRITING_PROFILE" in intern, "writing profile is labeled") expect("Ü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("\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("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", headers=headers, json={"calendar_date": "2026-08-25"}, ) 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\nkirschen\nspäter hafen"}, ) notes_gen = client.post( f"/api/journal/days/{notes_day.json()['day']['id']}/generate", headers=headers, json={"conversation_ids": [notes_conv.json()["id"]]}, ) expect(notes_gen.status_code == 200, f"notes generate {notes_gen.text}") notes_intern = intern_of(notes_gen.json()) 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, "Ich schreibe in kurzen, ruhigen Sätzen und lasse den Tag stehen. Der Hafen blieb hinter der Fähre.", occurred_at="2026-07-01", ) examples = select_journal_style_examples(profile_id, exclude_dates=["2026-08-26"]) expect(examples and "ruhigen Sätzen" in examples[0]["excerpt"], "imported text is a style example when no finals exist") expect(all("zimlich" not in (item.get("excerpt") or "") for item in examples), "current day is not selected as style") saved = client.post( "/api/journal/entries", headers=headers, json={ "journal_day_id": notes_day.json()["day"]["id"], "title": "Eigene Fassung", "body": "Heute blieb ich beim klaren Schnitt und schrieb den Markt in eigenen Worten, ohne Pathos.", "origin": "user_edit", "source_conversation_ids": [notes_conv.json()["id"]], }, ) expect(saved.status_code == 200, f"save {saved.text}") ranked = select_journal_style_examples(profile_id, exclude_dates=["2026-08-26"]) expect(ranked and ranked[0]["kind"] == "journal_entry", "final journal text outranks imports") expect("klaren Schnitt" in ranked[0]["excerpt"], "user-edited journal text is the preferred example") expect(len(ranked) <= 2, "at most two style examples") update_facet(profile_id, "core", value="Kurze Sätze, trockener Schnitt, keine Pathoswolken.") expect("Core:" not in compile_task_brief(profile_id), "unconfirmed core is not a style authority") set_lifecycle(profile_id, "confirmed") with_core = compile_task_brief(profile_id) 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, "Hanna", "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, "spelling is corrected") expect("zimlich" not in body, "source typo is not kept") expect("Ich ging dann zum Markt" in body, "good rewritten wording may stay") expect("Vielleicht bleibe ich kürzer" in body, "uncertainty stays uncertainty") expect("wollte" in body.lower() and "nicht gemacht" in body, "plan is not turned into completion") expect("nicht gemacht" in body, "negation is kept") expect("rote Tasche" in body, "unique attested detail is kept") 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("\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, "high similarity does not trigger a retry", ) expect((second.json().get("trace") or {}).get("lexical_similarity") is not None, "similarity is a diagnostic only") def notes_prose(_messages, _policy): return ChatResult( content="Notizen\n\nAm Markt holte ich Kirschen. Später war ich am Hafen.", model="fake", usage={}, context_compression="disabled", ) with patch("privacy_gateway.complete_model", notes_prose): notes_out = client.post( f"/api/journal/days/{notes_day.json()['day']['id']}/generate", headers=headers, json={"conversation_ids": [notes_conv.json()["id"]]}, ) notes_body = notes_out.json().get("body") or "" 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.", model="fake", usage={}, context_compression="disabled", ) with patch("privacy_gateway.complete_model", invent_title): 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 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( "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"] == SEED_REVISION, "revision updates even when template is custom") print("journal editorial tests passed.") if __name__ == "__main__": main()