"""First journal impulse invariants. Run from backend/: python tests/test_journal_opening.py""" from __future__ import annotations import os import sys import tempfile from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) os.environ["KANSHO_DB_PATH"] = str(Path(tempfile.gettempdir()) / "kansho-opening-test.sqlite") os.environ["KANSHO_MEDIA_ROOT"] = str(Path(tempfile.gettempdir()) / "kansho-opening-media") os.environ["KANSHO_FAKE_PROVIDER"] = "1" os.environ["KANSHO_FAKE_DETECT"] = "1" Path(os.environ["KANSHO_DB_PATH"]).unlink(missing_ok=True) Path(os.environ["KANSHO_MEDIA_ROOT"]).mkdir(parents=True, exist_ok=True) from fastapi.testclient import TestClient from main import app from journal_opening import ( NEUTRAL_OPENING, attested_plans_from_user_texts, collect_opening_context, is_attested_plan, ) from journal_reconstruct import assign_source_ids from retrieval import pair_user_priority_messages 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 open_space_day(client, headers, title, date): space = client.post("/api/journal/spaces", headers=headers, json={"title": title}) day = client.post( f"/api/journal/spaces/{space.json()['id']}/days", headers=headers, json={"calendar_date": date}, ) return space.json(), day.json() def start_conv(client, headers, day_id): return client.post(f"/api/journal/days/{day_id}/conversations", headers=headers, json={"title": "Gespräch"}) def main() -> None: expect(is_attested_plan("Morgen wollen wir nach Cres fahren."), "future intent is a plan") expect(is_attested_plan("Wir haben vor, später an den Hafen zu gehen."), "vorhaben is a plan") expect(not is_attested_plan("Heute war der Hafen ruhig. Kirschen am Stand."), "past report is not a plan") expect(not is_attested_plan("Wir standen vor dem Hafen und aßen Kirschen."), "vor without haben is not a plan") expect(not is_attested_plan("Hafen Kirschen Wind"), "word overlap is not a plan") plans = attested_plans_from_user_texts( ["Heute war der Hafen ruhig.", "Morgen wollen wir nach Cres fahren.", "Hafen Kirschen"] ) expect(len(plans) == 1 and "Cres" in plans[0], "only attested plan sentences are kept") expect( not any(is_attested_plan(text) for text in ["Was davon möchtest du festhalten?", "Die Fahrt nach Cres war schön."]), "assistant-like completion is not treated as a user plan by the helper", ) 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"]) other = client.post( "/api/users", headers=headers, json={"email": "ute@example.test", "name": "Ute", "password": "user-pass", "role": "user"}, ) expect(other.status_code == 200, "second profile") other_headers = header( client.post("/api/auth/login", json={"email": "ute@example.test", "password": "user-pass"}).json()["token"] ) empty_space, empty_day = open_space_day(client, headers, "Leer", "2026-08-21") empty = start_conv(client, headers, empty_day["day"]["id"]) expect(empty.status_code == 200, f"empty opening {empty.text}") expect(empty.json().get("opening", {}).get("kind") == "local_neutral", "no context uses local neutral") expect(empty.json()["opening"]["assistant"]["body"] == NEUTRAL_OPENING, "neutral impulse stored") expect(empty.json()["opening"]["calls"] == 0, "neutral opening does not call a model") empty_msgs = empty.json().get("messages") or [] expect(len(empty_msgs) == 1 and empty_msgs[0]["role"] == "assistant", "neutral opening is assistant-first") plan_space, plan_day = open_space_day(client, headers, "Urlaub", "2026-08-01") plan_conv = start_conv(client, headers, plan_day["day"]["id"]) client.post( f"/api/journal/conversations/{plan_conv.json()['id']}/turn", headers=headers, json={"body": "Morgen wollen wir nach Cres fahren. Die Fähre ist schon bedacht."}, ) later = client.post( f"/api/journal/spaces/{plan_space['id']}/days", headers=headers, json={"calendar_date": "2026-08-02"}, ) planned = start_conv(client, headers, later.json()["day"]["id"]) facts = collect_opening_context(setup.json()["profile_id"], planned.json()["id"]) expect(facts["has_relevant_context"], "attested plan counts as relevant context") expect(any("Cres" in item and is_attested_plan(item) for item in facts["attested_plans"]), "plan comes from user wording") expect(all(item in facts["user_texts"] for item in facts["attested_plans"]), "plans are a subset of user texts") expect(planned.json().get("opening", {}).get("kind") == "model", "attested plan uses the dialogue path") intern = (planned.json().get("opening") or {}).get("trace") or {} intern_text = intern.get("intern") or "" expect("Belegte Vorhaben" in intern_text, "opening policy reaches the prompt") expect("kein bewiesenes wiederkehrendes Muster" in intern_text or "kein bewiesenes" in intern_text, "pattern rule is in the opening hint") thread_space, thread_day = open_space_day(client, headers, "Tagfaden", "2026-08-10") first = start_conv(client, headers, thread_day["day"]["id"]) client.post( f"/api/journal/conversations/{first.json()['id']}/turn", headers=headers, json={"body": "Morgens am Markt, danach noch offen, wie der Nachmittag wird."}, ) second = start_conv(client, headers, thread_day["day"]["id"]) thread_facts = collect_opening_context(setup.json()["profile_id"], second.json()["id"]) expect(thread_facts["open_day_points"], "same-day user text is an open point") expect( any("Markt" in item or "Nachmittag" in item for item in thread_facts["open_day_points"]), "open point is the day's user wording", ) expect(second.json().get("opening", {}).get("kind") == "model", "open day thread uses the dialogue path") overlap_space, overlap_day = open_space_day(client, headers, "Overlap", "2026-08-03") overlap_conv = start_conv(client, headers, overlap_day["day"]["id"]) client.post( f"/api/journal/conversations/{overlap_conv.json()['id']}/turn", headers=headers, json={"body": "Heute war der Hafen ruhig. Kirschen am Stand, Wind von See."}, ) client.post( "/api/journal/entries", headers=headers, json={ "journal_day_id": overlap_day["day"]["id"], "title": "Hafen", "body": "Heute war der Hafen ruhig. Kirschen am Stand.", "origin": "user_edit", }, ) later_overlap = client.post( f"/api/journal/spaces/{overlap_space['id']}/days", headers=headers, json={"calendar_date": "2026-08-04"}, ) overlap_open = start_conv(client, headers, later_overlap.json()["day"]["id"]) overlap_facts = collect_opening_context(setup.json()["profile_id"], overlap_open.json()["id"]) expect(not overlap_facts["attested_plans"], "random overlap is not an attested plan") expect(not overlap_facts["has_relevant_context"], "recency without plan or open day is not relevant opening context") expect(overlap_open.json().get("opening", {}).get("kind") == "local_neutral", "overlap falls back to neutral") expect("Muster" not in (overlap_open.json().get("opening") or {}).get("assistant", {}).get("body", ""), "neutral impulse does not claim a pattern") asst_space, asst_day = open_space_day(client, headers, "Assistent", "2026-08-05") asst_conv = start_conv(client, headers, asst_day["day"]["id"]) from dialogue_store import append_message append_message( setup.json()["profile_id"], asst_conv.json()["id"], "Morgen wollen wir nach Cres fahren.", role="assistant", ) next_asst = client.post( f"/api/journal/spaces/{asst_space['id']}/days", headers=headers, json={"calendar_date": "2026-08-06"}, ) asst_open = start_conv(client, headers, next_asst.json()["day"]["id"]) asst_facts = collect_opening_context(setup.json()["profile_id"], asst_open.json()["id"]) expect(not asst_facts["attested_plans"], "assistant wording is not a user plan") expect("Cres" not in " ".join(asst_facts["attested_plans"]), "assistant plan sentence stays out of attested plans") expect(asst_facts["assistant_is_not_user_fact"], "adapter marks assistant as non-fact") pair_space, pair_day = open_space_day(client, headers, "Paarung", "2026-08-07") pair_conv = start_conv(client, headers, pair_day["day"]["id"]) expect((pair_conv.json().get("messages") or [])[0]["role"] == "assistant", "assistant-first stored") user_turn = client.post( f"/api/journal/conversations/{pair_conv.json()['id']}/turn", headers=headers, json={"body": "Nach der Fähre kamen wir spät an. Anna kaufte Brot."}, ) expect(user_turn.status_code == 200, "user turn after opening") msgs = user_turn.json()["messages"] users = [item for item in msgs if item["role"] == "user"] expect(len(users) == 1, "exactly one user source after opening plus turn") paired = pair_user_priority_messages(msgs) paired_users = [item for item in paired if item["role"] == "user"] expect([item["body"] for item in paired_users] == [item["body"] for item in users], "pairing keeps every user source") labeled = assign_source_ids(paired) expect( [item["source_id"] for item in labeled if item["role"] == "user"] == ["u1"], "first user source is u1 even after an opening assistant", ) draft = client.post( f"/api/journal/days/{pair_day['day']['id']}/generate", headers=headers, json={"conversation_ids": [pair_conv.json()["id"]]}, ) expect(draft.status_code == 200, f"generate after opening {draft.text}") reconstruct = None for stage in (draft.json().get("trace") or {}).get("stages") or []: if stage.get("purpose") == "journal_reconstruct": reconstruct = stage intern = (reconstruct or {}).get("intern") or "" expect("Anna kaufte Brot" in intern, "user source reaches reconstruction") expect("[u1]" in intern, "user source id assigned") narrate = None for stage in (draft.json().get("trace") or {}).get("stages") or []: if stage.get("purpose") == "journal_generate": narrate = stage narrate_intern = (narrate or {}).get("intern") or "" expect("verified_artifact" in narrate_intern or "sources" in narrate_intern, "stage 2 consumes verified artifact") expect("Nach der Fähre kamen wir spät an" in intern, "user wording not dropped after opening") expect(users[0]["body"].startswith("Nach der Fähre"), "user source is the user message, not the opening") stolen = client.post( f"/api/journal/days/{empty_day['day']['id']}/conversations", headers=other_headers, json={"title": "fremd"}, ) expect(stolen.status_code == 404, "foreign profile cannot open a conversation") stolen_turn = client.post( f"/api/journal/conversations/{empty.json()['id']}/turn", headers=other_headers, json={"body": "Hallo"}, ) expect(stolen_turn.status_code == 404, "foreign profile cannot turn into the opening conversation") user_login_space = client.post("/api/journal/spaces", headers=other_headers, json={"title": "Ute"}) user_day = client.post( f"/api/journal/spaces/{user_login_space.json()['id']}/days", headers=other_headers, json={"calendar_date": "2026-08-21"}, ) user_open = start_conv(client, other_headers, user_day.json()["day"]["id"]) expect(user_open.status_code == 200, "non-admin can start their own day") expect("trace" not in (user_open.json().get("opening") or {}), "non-admin opening has no trace") expect("opening_context" not in (user_open.json().get("opening") or {}), "non-admin opening has no opening_context") expect("trace" not in user_open.json(), "non-admin response has no top-level trace") start = client.get("/api/journal/continuity", headers=headers) expect(start.status_code == 200 and start.json().get("continuable"), "continuable day for the author") expect(start.json()["continuable"]["space_id"] == start.json()["continuable"]["space_id"], "continuity names a space") other_start = client.get("/api/journal/continuity", headers=other_headers) expect(other_start.json()["continuable"]["space_id"] == user_login_space.json()["id"], "continuity is profile-scoped") stolen_cont = client.get(f"/api/journal/spaces/{plan_space['id']}", headers=other_headers) expect(stolen_cont.status_code == 404, "continuity space stays isolated") if __name__ == "__main__": main()