Kansho/backend/tests/test_journal_narration.py

300 lines
15 KiB
Python

"""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_DB_PATH"] = str(Path(tempfile.gettempdir()) / "kansho-journal-narration-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_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("Faktentreue ist nicht Wortlauttreue" in text, "active prompt separates fact fidelity from wording")
expect("CURRENT_DAY_SOURCES" in text, "active prompt labels current-day facts")
expect("STYLE_EXAMPLES" in text, "active prompt labels style examples")
expect("Rechtschreibung" in text or "korrigieren" in text, "active prompt allows spelling and grammar fixes")
expect("Unsicherheit bleibt Unsicherheit" in text, "active prompt keeps semantic uncertainty")
expect("keine neuen Informationen" in text.lower() or "Keine neuen Informationen" in text, "active prompt still forbids new facts")
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")
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-27-journal-placeholders-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",
)
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("CURRENT_DAY_SOURCES")[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("Faktentreue ist nicht Wortlauttreue" 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("CURRENT_DAY_SOURCES")[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 == 200, f"unattested generate {blocked.text}")
log = blocked.json().get("run_log") or []
expect(any(item.get("reason") == "unattested_identity" for item in log), "unattested person uses local fallback")
expect(any(item.get("status") == "local_fallback" for item in log), "fallback path is distinct from the model path")
expect("Clarissa" not in (blocked.json().get("body") or ""), "unattested person is not in the accepted draft")
expect("markt" in (blocked.json().get("body") or "").lower(), "fallback still has the user sources")
expect("rote tasche" in (blocked.json().get("body") or "").lower(), "fallback keeps the unique source detail")
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("Faktentreue ist nicht Wortlauttreue" in (row["default_template"] or ""), "default template still tracks the seed")
expect(row["seed_revision"] == "2026-08-27-journal-placeholders-v1", "revision updates even when template is custom")
print("journal narration tests passed.")
if __name__ == "__main__":
main()