"""MVP journal acceptance cases A–K. Run from backend/: python tests/test_mvp_journal.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)) from tests.harness import configure_test_engine configure_test_engine() os.environ["KANSHO_MEDIA_ROOT"] = str(Path(tempfile.gettempdir()) / "kansho-mvp-journal-media") os.environ["KANSHO_FAKE_PROVIDER"] = "1" os.environ["KANSHO_FAKE_DETECT"] = "1" Path(os.environ["KANSHO_MEDIA_ROOT"]).mkdir(parents=True, exist_ok=True) from fastapi.testclient import TestClient from main import app from privacy_gateway import reset_debug 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} PNG = bytes.fromhex( "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489" "0000000a49444154789c6360000002000100ffff03000006000557bf0000000049454e44ae426082" ) def setup_client() -> tuple[TestClient, dict, str]: client = TestClient(app) client.__enter__() setup = client.post( "/api/auth/setup", json={"email": "lars@example.test", "name": "Lars", "password": "test-pass"}, ) token = setup.json()["token"] return client, header(token), setup.json()["profile_id"] def open_space_day(client, headers, title="Alltag", date="2026-08-20"): space = client.post("/api/journal/spaces", headers=headers, json={"title": title}) expect(space.status_code == 200, f"create space {space.text}") day = client.post( f"/api/journal/spaces/{space.json()['id']}/days", headers=headers, json={"calendar_date": date}, ) expect(day.status_code == 200, f"open day {day.text}") return space.json(), day.json() def start_and_turn(client, headers, day_id, body, title="Gespräch"): conv = client.post( f"/api/journal/days/{day_id}/conversations", headers=headers, json={"title": title}, ) expect(conv.status_code == 200, f"start conversation {conv.text}") reset_debug() turn = client.post( f"/api/journal/conversations/{conv.json()['id']}/turn", headers=headers, json={"body": body}, ) expect(turn.status_code == 200, f"turn {turn.text}") return conv.json(), turn.json() def main() -> None: reset_debug() client, headers, _profile_id = setup_client() try: 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_login = client.post("/api/auth/login", json={"email": "ute@example.test", "password": "user-pass"}) other_headers = header(other_login.json()["token"]) space, day = open_space_day(client, headers, "Alltag", "2026-08-20") space_id, day_id = space["id"], day["day"]["id"] expect(day["day"]["calendar_date"] == "2026-08-20", "calendar date stored from client") again = client.post( f"/api/journal/spaces/{space_id}/days", headers=headers, json={"calendar_date": "2026-08-20"}, ) expect(again.json()["day"]["id"] == day_id, "unique space+date") hidden = client.get(f"/api/journal/spaces/{space_id}", headers=other_headers) expect(hidden.status_code == 404, "space isolated by profile") conv, turn = start_and_turn( client, headers, day_id, "Heute war der Markt voll. Ich bin früh gegangen und habe Kirschen gekauft.", ) expect(turn["user"]["body"].startswith("Heute war der Markt"), "A source user persisted") expect(turn["assistant"]["role"] == "assistant", "A assistant persisted") expect("Was davon" in turn["assistant"]["body"], "A impulse is visible text") expect("{" not in turn["assistant"]["body"], "A raw json not shown as message") expect(turn["decision"]["operation"] == "erleben_vertiefen", "A operation recorded") expect(turn["trace"]["egress"], "A admin sees egress") expect(turn["calls"] == 1, "A one generative call") intern = turn["trace"].get("intern") or "" expect("Formulierungen aus dem Dialog" not in intern, "dialogue turn does not paste other-day excerpts") expect("Kirschen" in intern, "current user line stays in dialogue context") style = client.get("/api/journal/writing-profile", headers=headers).json() expect(any(item.get("kind") == "dialogue_style" for item in style["sources"]), "A dialogue style remembered") expect("Kirschen" in (style.get("compiled_brief") or ""), "A user wording in brief") expect( all("Was davon" not in (item.get("body") or "") for item in style["sources"]), "A assistant text is not a style source", ) from dialogue_turn import ( is_completion_ask, is_dann_probe, is_day_arc_recap, is_echo, is_interview_question, is_machine_tell, is_next_beat_question, is_plot_continuation, is_pure_recap, is_recap_then_ask, is_reopen_after_close, is_unearned_completion, is_unearned_stance, is_verbless_echo, local_hold, needs_repair, user_closed_day, parse_turn_payload, visible_for_role, ) impulse, decision = parse_turn_payload('{"operation":"konkretisieren","impulse":"Wo war das?"}') expect(impulse == "Wo war das?" and decision["parsed"] is True, "operation json parsed") hidden = visible_for_role({"trace": {"egress": "x"}, "run_log": [{"kind": "detect"}], "decision": decision, "calls": 1}, "user") expect("trace" not in hidden and "decision" not in hidden, "user does not see test spur") expect(hidden.get("run_log"), "compact generate log remains visible") expect(is_plot_continuation("Und dann mit dem Tee auf dem Balkon?"), "echo then-question rejected") expect(is_plot_continuation("Ihr seid dann zur Bootstour aufgebrochen."), "invented departure rejected") expect(is_plot_continuation("Und dann seid ihr zur Wanderung aufgebrochen."), "invented hike rejected") expect( not is_plot_continuation( "Der Morgen war klar, nicht schwül. Und dann seid ihr zusammen zum Bäcker." ), "restating the last shop visit is not the next beat", ) expect( is_unearned_stance( "Der Morgen war klar – das hat sich sicher anders angefühlt als die Tage zuvor." ), "invented comparison to other days", ) expect(not is_plot_continuation("Die japanische Lektion liegt noch offen."), "companion impulse kept") expect(is_interview_question("Was geschah in der Zeit bis zum Start?"), "interview opener detected") expect(is_unearned_completion("Was holtet ihr im Laden?"), "shop completion not earned") expect( not needs_repair( "Was geschah in der Zeit bis zum Start?", {"register_hint": "dichte Chronik. Begleite, unterbrich wenig."}, ), "companion may ask; plot guard does not treat every question as repair", ) expect( not needs_repair("Die Gassen kennt ihr schon fast blind.", {"register_hint": "Keine Interviewfrage"}), "companion sentence kept without source", ) bakery = ( "Im Laden entdeckte ich Haferflocken und Trockenobst, die ich ab morgen essen wollte." ) expect( is_echo("Haferflocken und Trockenobst lagen für morgen bereit.", bakery), "restatement is echo", ) expect(is_unearned_stance("Das warme Wasser fühlte sich anders an als erwartet."), "invented sensation") expect(is_unearned_stance("Der Weg zurück musste noch schneller gehen."), "next station as necessity") expect( not needs_repair( "Haferflocken und Trockenobst lagen für morgen bereit.", {"dialogue_context": f"user: {bakery}"}, ), "word overlap alone is not a plot invention", ) expect(is_dann_probe("Was hat sich dann gezeigt?"), "dann-probe rejected") expect(is_completion_ask("Ob ihr rechtzeitig losgekommen seid."), "completion ask rejected") balcony = "Sushi kam auf den Balkon. Bis zum Start um 11:00 Uhr hatten wir noch Zeit." expect( is_verbless_echo("Die Zeit bis 11:00 Uhr auf dem Balkon.", balcony), "setting fragment is echo", ) expect("Ich bleibe bei dem" not in local_hold("Wir gingen zum Bäcker."), "fallback is not a system sentence") expect("geöffnet" not in local_hold("Wir gingen zum Bäcker."), "fallback is spoken, not a register") expect("noch da" not in local_hold("Wir gingen zum Bäcker."), "fallback does not report presence") expect(local_hold("Wir gingen zum Bäcker.") == "Ich bin gespannt, wie es weitergeht.", "short hold invites continuation") long_hold = " ".join(["Wort"] * 16) expect(local_hold(long_hold) == "Erzähl bitte weiter.", "narrative hold asks to continue") expect(is_machine_tell("Danke, dass du das teilst."), "gratitude template is a machine tell") expect(is_machine_tell("Zusammengefasst war der Vormittag ruhig."), "summary marker is a machine tell") expect(not is_machine_tell("Die Nektarine als Ersatz – das setzt sich fest."), "companion thought is not a machine tell") expect(needs_repair("Interessant. Lass uns das gemeinsam anschauen."), "machine tell is repaired") expect(needs_repair("Ihr seid dann zur Bootstour aufgebrochen."), "invented departure still repaired") expect(is_next_beat_question("Also seid ihr dann weitergegangen?"), "second-person then-question is next beat") expect(is_next_beat_question("Seid ihr dann auch direkt gesprungen?"), "completion of an intention is next beat") expect(not is_next_beat_question("Was gab's dann zu Mittag?"), "a short next-thought question is not a plot beat") expect(not is_next_beat_question("Wie war das dann mit der Gruppe?"), "dann in a felt question stays") oats = ( "Ich habe wie geplant meine Haferflocken mit Trockenobst. " "Sushi hat Wäsche gewaschen und ich habe am Tagebuch gearbeitet. " "Die Kinder sind gegen 12 Uhr aus dem Bett gekrochen und wollten Mittag essen." ) recap_ask = ( "Die Haferflocken scheinen die neue Lösung zu sein. Und dann Wäsche, Tagebuch, " "die Kinder, Rouven. Was gab's dann zu Mittag?" ) expect( is_recap_then_ask(recap_ask, {"dialogue_context": f"user: {oats}"}), "recap plus what-next is an interview", ) expect( needs_repair(recap_ask, {"dialogue_context": f"user: {oats}"}), "interview recap is repaired", ) expect( not needs_repair( "Was gab's dann zu Mittag?", {"dialogue_context": f"user: {oats}"}, ), "short next-thought impulse without recap stays", ) recap_only = ( "Haferflocken mit Trockenobst, dann Wäsche und Tagebuch, " "danach die Arbeit, bis die Kinder aus dem Bett wollten Mittag essen." ) expect( is_pure_recap(recap_only, {"dialogue_context": f"user: {oats}"}), "dense restatement without a break is a recap", ) expect( needs_repair(recap_only, {"dialogue_context": f"user: {oats}"}), "pure recap without contradiction is repaired", ) expect( not is_pure_recap( "Die Haferflocken bleiben die neue Lösung.", {"dialogue_context": f"user: {oats}"}, ), "a thought derived from the last thread is not a recap", ) close_ctx = { "dialogue_context": ( "user: Morgens nach der Japanischlektion kam jemand auf den Balkon.\n" "user: Später die Bootstour, vier Badestopps, gegrillter Fisch und singende Gäste.\n" "user: Wir genossen noch die Atmosphäre und machten uns dann müde " "und voll von den überwältigenden Eindrücken des Tages auf den Heimweg." ) } expect( user_closed_day( {"dialogue_context": "user: Wir gingen zurück zur Wohnung und legten uns dann auch bald ins Bett."} ), "ins Bett closes the day", ) expect( not user_closed_day( {"dialogue_context": "user: Nach dem Essen gingen die Kinder nach Hause und wir wollten noch zum Hafen."} ), "nach Hause mid-day is not a close", ) expect( needs_repair( "Was war das für ein Gefühl, müde heimzugehen mit all diesen Eindrücken?", close_ctx, ), "feeling question after close is repaired", ) expect( is_reopen_after_close( "Du hattest morgens nicht ahnen können, dann Bootstour, Badestopps, Essen, Italiener.", close_ctx, ), "day-arc recap after close is repaired", ) expect(is_day_arc_recap("Morgens Japanisch, Balkon, Bootstour und Badestopps.", close_ctx), "arc uses earlier stations") expect(not needs_repair("Dann bleibe ich bei diesem Heimweg.", close_ctx), "hold at the close stays") expect("Schluss" in local_hold(close_ctx["dialogue_context"].split("user:")[-1]), "close fallback holds the end") from context_builder import infer_register expect("Schluss" in infer_register(["Wir machten uns müde auf den Heimweg."]), "register sees the close first") originals = client.get(f"/api/journal/conversations/{conv['id']}", headers=headers) source_msgs = originals.json()["messages"] expect(sum(1 for item in source_msgs if item["role"] == "user") == 1, "A one user source") expect(any(item["role"] == "assistant" for item in source_msgs), "A assistant source present") reset_debug() draft = client.post(f"/api/journal/days/{day_id}/generate", headers=headers, json={"conversation_ids": [conv["id"]]}) expect(draft.status_code == 200, f"A generate {draft.text}") expect("Markt" in draft.json()["body"] or "Kirschen" in draft.json()["body"], "A draft keeps user words") stages_a = {item.get("purpose"): item for item in (draft.json().get("trace") or {}).get("stages") or []} reconstruct_a = stages_a.get("local_source_artifact") last_a = stages_a.get("journal_generate") or (draft.json().get("trace") or {}) expect(reconstruct_a is not None, "A stage 1 local artifact ran") reconstruct_intern = reconstruct_a.get("intern") or "" narrate_intern = last_a.get("intern") or "" expect("Kirschen" in reconstruct_intern or "Markt" in reconstruct_intern, "A local artifact sees user words") expect("assistant:" not in reconstruct_intern, "A local artifact has no assistant lines") expect("assistant:" not in narrate_intern, "A stage 2 does not resend the day dialogue") expect("CURRENT_DAY_SOURCES" in narrate_intern or "[u1]" in narrate_intern, "A stage 2 receives the day sources") expect( "WRITING_PROFILE" in narrate_intern or "Neutraler Journalstil" in narrate_intern or "Core:" in narrate_intern, "A style brief reaches generate", ) expect("STYLE_EXAMPLES" not in narrate_intern, "A default voice does not attach a style-examples block") style_part = narrate_intern.split("\nCURRENT_DAY_SOURCES\n")[0] expect("Erzählmerkmale" not in style_part, "A current day dialogue is not appended as style signals") expect("Neutraler Journalstil" in style_part or "Core:" in style_part, "A empty profile uses the neutral journal voice") expect( "Heute war der Markt voll" not in style_part, "A source dialogue is not pasted a second time as style", ) current_version = None saved = client.post( "/api/journal/entries", headers=headers, json={ "journal_day_id": day_id, "title": "Markttag", "body": "Heute war der Markt voll. Ich bin früh gegangen.", "origin": "user_edit", "source_conversation_ids": [conv["id"]], }, ) expect(saved.status_code == 200, f"A save {saved.text}") current_version = saved.json()["current_version_id"] expect(saved.json()["body"].startswith("Heute war der Markt"), "A user edit stored") after_save = client.get("/api/journal/writing-profile", headers=headers).json() brief = after_save.get("compiled_brief") or "" expect("Finale Journal Entries" in brief, "A saved entry is strongest style source") entry_at = brief.find("Finale Journal Entries") dialogue_at = brief.find("Formulierungen aus dem Dialog") expect(dialogue_at == -1 or entry_at < dialogue_at, "A entries listed before dialogue style") space_view = client.get(f"/api/journal/spaces/{space_id}", headers=headers) dates = [item["calendar_date"] for item in space_view.json()["days"]] expect("2026-08-20" in dates, "A day appears in space chronology") later = client.post( f"/api/journal/conversations/{conv['id']}/turn", headers=headers, json={"body": "Am nächsten Kalendertag fällt mir noch der Wind ein."}, ) expect(later.status_code == 200, "B continue same conversation") day_again = client.get(f"/api/journal/days/{day_id}", headers=headers) expect(day_again.json()["day"]["calendar_date"] == "2026-08-20", "B journal day stays calendar day") msgs = client.get(f"/api/journal/conversations/{conv['id']}", headers=headers).json()["messages"] expect(any("Wind" in item["body"] for item in msgs), "B later message kept") expect(not str(msgs[-1]["created"]).startswith("2026-08-20"), "B message time is independent of the calendar day") prior_space, prior_day = open_space_day(client, headers, "Urlaub", "2026-08-01") prior_conv, _ = start_and_turn( client, headers, prior_day["day"]["id"], "Morgen gehe ich an den Hafen, wenn das Wetter hält.", title="Plan", ) client.post( "/api/journal/entries", headers=headers, json={ "journal_day_id": prior_day["day"]["id"], "title": "Plan", "body": "Morgen gehe ich an den Hafen, wenn das Wetter hält.", "origin": "user_edit", }, ) next_day = client.post( f"/api/journal/spaces/{prior_space['id']}/days", headers=headers, json={"calendar_date": "2026-08-02"}, ) reset_debug() _conv_c, turn_c = start_and_turn( client, headers, next_day.json()["day"]["id"], "Der Vormittag war ruhig.", ) expect(turn_c is not None, "C gateway saw a request") intern_c = (turn_c.get("trace") or {}).get("intern") or "" expect("Hafen" in intern_c, "C prior plan in context") expect("nicht geschehen" in intern_c, "C not-mentioned rule in prompt") reset_debug() dense = "Erster Absatz mit vielen Details über die Anreise.\n\nZweiter Absatz über das Licht am Abend.\n\nDritter Absatz über das Essen." conv_d, turn_d = start_and_turn(client, headers, day_id, dense, title="Dicht") from privacy_gateway import debug_calls as calls_d expect(calls_d == 1, f"D exactly one call, got {calls_d}") expect(sum(1 for item in turn_d["messages"] if item["role"] == "assistant" and item["conversation_id"] == conv_d["id"]) >= 1, "D one assistant") expect(turn_d["calls"] == 1, "D turn reports one call") reset_debug() _conv_e, turn_e = start_and_turn( client, headers, day_id, "Ich bin traurig über den Abschied, das ist klar.", title="Emotion", ) intern_e = (turn_e.get("trace") or {}).get("intern") or "" expect("Motivationen erfinden" in intern_e, "E no invented emotion rule") expect(turn_e["calls"] == 1, "E one follow-up") day_f = client.post( f"/api/journal/spaces/{space_id}/days", headers=headers, json={"calendar_date": "2026-08-21"}, ) day_f_id = day_f.json()["day"]["id"] conv_f1, _ = start_and_turn(client, headers, day_f_id, "Chronik: Spaziergang und Einkauf.", title="Chronik") conv_f2, _ = start_and_turn(client, headers, day_f_id, "Tiefer: der Spaziergang hat mich berührt.", title="Tiefe") gen_f1 = client.post( f"/api/journal/days/{day_f_id}/generate", headers=headers, json={"conversation_ids": [conv_f1["id"]]}, ) save_f1 = client.post( "/api/journal/entries", headers=headers, json={"journal_day_id": day_f_id, "title": "Chronik", "body": gen_f1.json()["body"], "origin": "accepted_draft"}, ) gen_f2 = client.post( f"/api/journal/days/{day_f_id}/generate", headers=headers, json={"conversation_ids": [conv_f2["id"]]}, ) save_f2 = client.post( "/api/journal/entries", headers=headers, json={"journal_day_id": day_f_id, "title": "Tiefe", "body": gen_f2.json()["body"], "origin": "accepted_draft"}, ) day_f_view = client.get(f"/api/journal/days/{day_f_id}", headers=headers).json() expect(len(day_f_view["entries"]) == 2, "F two separate entries") expect(save_f1.json()["id"] != save_f2.json()["id"], "F distinct entry ids") expect(day_f_view["consolidation_offer"] is False, "G no offer when chronicle and deep reflection differ") gen_g = client.post(f"/api/journal/days/{day_f_id}/generate", headers=headers, json={}) expect(len(gen_g.json()["source_conversation_ids"]) == 1, "G generate without selection does not merge") day_g2 = client.post( f"/api/journal/spaces/{space_id}/days", headers=headers, json={"calendar_date": "2026-08-19"}, ) day_g2_id = day_g2.json()["day"]["id"] start_and_turn( client, headers, day_g2_id, "Heute Vormittag zum Markt, danach Kirschen gekauft.", title="Morgen", ) start_and_turn( client, headers, day_g2_id, "Nach dem Markt noch Brot geholt, dann heimgegangen.", title="Abend", ) day_g2_view = client.get(f"/api/journal/days/{day_g2_id}", headers=headers).json() expect(day_g2_view["consolidation_offer"] is True, "G offer when two chronicle conversations match") space_h, day_h = open_space_day(client, headers, "Kleinwalsertal 2026", "2026-08-21") expect(day_h["day"]["id"] != day_f_id, "H same date different space is other day") listed_alltag = {item["id"] for item in client.get(f"/api/journal/spaces/{space_id}", headers=headers).json()["days"]} listed_kw = {item["id"] for item in client.get(f"/api/journal/spaces/{space_h['id']}", headers=headers).json()["days"]} expect(day_f_id in listed_alltag and day_h["day"]["id"] not in listed_alltag, "H days stay in own space") expect(day_h["day"]["id"] in listed_kw and day_f_id not in listed_kw, "H no date merge across spaces") imported = client.post( "/api/journal/writing-profile/import", headers=headers, json={"body": "Ich schreibe knapp und ohne Pathos."}, ) expect(imported.status_code == 200, f"I import {imported.text}") kinds = {item["kind"] for item in imported.json()["sources"]} expect("imported_text" in kinds, "I import is a source") expect(any("knapp" in (item.get("body") or "") for item in imported.json()["sources"]), "I import body kept") expect(imported.json()["compiled_brief"] and "knapp" in imported.json()["compiled_brief"], "I brief uses import") reset_debug() gen_i = client.post( f"/api/journal/days/{day_id}/generate", headers=headers, json={"conversation_ids": [conv["id"]]}, ) expect(gen_i.status_code == 200, "I generate after import") intern_i = "" for stage in (gen_i.json().get("trace") or {}).get("stages") or []: if stage.get("purpose") == "journal_generate": intern_i = stage.get("intern") or "" intern_i = intern_i or ((gen_i.json().get("trace") or {}).get("intern") or "") expect("STYLE_EXAMPLES" not in intern_i, "I default voice does not send imported texts as style examples") expect("WRITING_PROFILE" in intern_i or "Neutraler Journalstil" in intern_i, "I writing profile reaches generate") expect("knapp" in imported.json()["compiled_brief"], "I stored brief still lists the import") profile = client.get("/api/journal/writing-profile", headers=headers).json() expect(all(item.get("kind") != "journal_draft" for item in profile["sources"]), "I no draft kind") expect(not any((item.get("body") or "") == gen_i.json()["body"] for item in profile["sources"]), "I draft not a style source") expect(any(item.get("kind") == "journal_entry" for item in profile["sources"]), "I user-final entry is a source") first = client.post( "/api/journal/entries", headers=headers, json={"journal_day_id": day_id, "entry_id": saved.json()["id"], "title": "v1", "body": "Erste Fassung.", "origin": "user_edit"}, ) second = client.post( "/api/journal/entries", headers=headers, json={"journal_day_id": day_id, "entry_id": saved.json()["id"], "title": "v2", "body": "Zweite Fassung.", "origin": "user_edit"}, ) versions = client.get(f"/api/journal/entries/{saved.json()['id']}/versions", headers=headers).json() expect(len(versions) >= 3, f"J version history kept ({len(versions)})") restore = client.post( f"/api/journal/entries/{saved.json()['id']}/restore", headers=headers, json={"version_id": first.json()["current_version_id"]}, ) expect(restore.status_code == 200, f"J restore {restore.text}") expect(restore.json()["body"] == "Erste Fassung.", "J restored body") versions_after = client.get(f"/api/journal/entries/{saved.json()['id']}/versions", headers=headers).json() expect(len(versions_after) == len(versions) + 1, "J restore appends a version") before_gen = restore.json()["current_version_id"] client.post(f"/api/journal/days/{day_id}/generate", headers=headers, json={"conversation_ids": [conv["id"]]}) after_gen = client.get(f"/api/journal/entries/{saved.json()['id']}", headers=headers).json() expect(after_gen["current_version_id"] == before_gen, "J generate does not overwrite entry") expect(after_gen["body"] == "Erste Fassung.", "J user text remains") day_with_draft = client.get(f"/api/journal/days/{day_id}", headers=headers).json() expect(day_with_draft.get("current_draft"), "J generate stores a draft beside the entry") expect( (day_with_draft["current_draft"] or {}).get("body") != after_gen["body"], "J new draft is not the saved user text", ) marker = "EXISTING_FASSUNG_XYZ_UNIQUE" marked = client.post( "/api/journal/entries", headers=headers, json={ "journal_day_id": day_id, "entry_id": saved.json()["id"], "title": "Markiert", "body": marker, "origin": "user_edit", }, ) expect(marked.status_code == 200, "include_existing setup save") marked_version = marked.json()["current_version_id"] without_flag = client.post( f"/api/journal/days/{day_id}/generate", headers=headers, json={"conversation_ids": [conv["id"]]}, ) expect(without_flag.status_code == 200, f"generate default {without_flag.text}") without_intern = "" for stage in (without_flag.json().get("trace") or {}).get("stages") or []: if stage.get("purpose") == "journal_generate": without_intern = stage.get("intern") or "" expect(marker not in without_intern, "include_existing default does not send the saved body") after_without = client.get(f"/api/journal/entries/{saved.json()['id']}", headers=headers).json() expect(after_without["current_version_id"] == marked_version, "default generate leaves entry version") with_flag = client.post( f"/api/journal/days/{day_id}/generate", headers=headers, json={"conversation_ids": [conv["id"]], "include_existing": True}, ) expect(with_flag.status_code == 200, f"generate include_existing {with_flag.text}") with_intern = "" for stage in (with_flag.json().get("trace") or {}).get("stages") or []: if stage.get("purpose") == "journal_generate": with_intern = stage.get("intern") or "" expect(marker in with_intern, "include_existing true sends the saved body only after explicit flag") existing_section = with_intern.split("EXISTING_TEXT")[-1] expect(marker in existing_section, "saved Fassung is the included existing text, not a leftover draft") after_with = client.get(f"/api/journal/entries/{saved.json()['id']}", headers=headers).json() expect(after_with["current_version_id"] == marked_version, "include_existing generate still does not overwrite") expect(after_with["body"] == marker, "saved user body remains") day_with_draft = client.get(f"/api/journal/days/{day_id}", headers=headers).json() n_before = len(client.get(f"/api/journal/entries/{saved.json()['id']}/versions", headers=headers).json()) adopted = client.post( "/api/journal/entries", headers=headers, json={ "journal_day_id": day_id, "entry_id": saved.json()["id"], "title": "# Neue Überschrift", "body": (day_with_draft["current_draft"] or {}).get("body") or "Entwurf", "origin": "accepted_draft", }, ) expect(adopted.status_code == 200, f"J adopt draft {adopted.text}") expect(adopted.json()["title"] == "Neue Überschrift", "J hash stripped from stored title") listed = client.get(f"/api/journal/days/{day_id}", headers=headers).json() titles = [item.get("title") or "" for item in listed.get("entries") or []] expect(any("Neue Überschrift" in title for title in titles), "J day list shows cleaned title") n_after = len(client.get(f"/api/journal/entries/{saved.json()['id']}/versions", headers=headers).json()) expect(n_after == n_before + 1, "J adopt appends a version on the same entry") look = client.get(f"/api/journal/spaces/{prior_space['id']}", headers=headers).json() expect(any(item["calendar_date"] == "2026-08-01" for item in look["days"]), "K space chronology") opened = client.post( f"/api/journal/spaces/{prior_space['id']}/days", headers=headers, json={"calendar_date": "2026-08-01"}, ) expect(opened.json()["entries"], "K entry opens by date") source = client.get( f"/api/journal/conversations/{opened.json()['conversations'][0]['id']}", headers=headers, ) expect(source.status_code == 200 and source.json()["messages"], "K jump to source dialog") throwaway, _ = start_and_turn(client, headers, day_id, "Nur ein Testdialog zum Entfernen.", title="Wegwerf") blocked = client.delete(f"/api/journal/conversations/{throwaway['id']}", headers=other_headers) expect(blocked.status_code == 404, "delete isolated by profile") removed = client.delete(f"/api/journal/conversations/{throwaway['id']}", headers=headers) expect(removed.status_code == 200 and removed.json().get("deleted"), "conversation deleted") after_delete = client.get(f"/api/journal/days/{day_id}", headers=headers).json() expect(all(item["id"] != throwaway["id"] for item in after_delete["conversations"]), "deleted conversation leaves the day") expect(any(item["id"] == conv["id"] for item in after_delete["conversations"]), "other conversations remain") missing = client.get(f"/api/journal/conversations/{throwaway['id']}", headers=headers) expect(missing.status_code == 404, "deleted conversation is gone") media = client.post( f"/api/journal/entries/{saved.json()['id']}/media", headers=headers, files={"file": ("shot.png", PNG, "image/png")}, ) expect(media.status_code == 200, f"media upload {media.text}") fetched = client.get(f"/api/journal/media/{media.json()['id']}", headers=headers) expect(fetched.status_code == 200 and fetched.content[:8] == b"\x89PNG\r\n\x1a\n", "media served") other_media = client.get(f"/api/journal/media/{media.json()['id']}", headers=other_headers) expect(other_media.status_code == 404, "media isolated") expect(media.json().get("kind") == "image", "image kind") video = client.post( f"/api/journal/entries/{saved.json()['id']}/media", headers=headers, files={"file": ("clip.webm", b"webm-bytes-not-a-container", "video/webm")}, ) expect(video.status_code == 200, f"video upload {video.text}") expect(video.json().get("kind") == "video", "video kind") served_video = client.get(f"/api/journal/media/{video.json()['id']}", headers=headers) expect(served_video.status_code == 200 and served_video.headers["content-type"].startswith("video/"), "video served") rejected = client.post( f"/api/journal/entries/{saved.json()['id']}/media", headers=headers, files={"file": ("note.pdf", b"%PDF-1.4", "application/pdf")}, ) expect(rejected.status_code == 400, "pdf rejected") media_id = media.json()["id"] video_id = video.json()["id"] embedded = client.post( "/api/journal/entries", headers=headers, json={ "journal_day_id": day_id, "entry_id": saved.json()["id"], "title": "Markttag", "body": ( "Heute war der Markt voll.\n\n" f"[[media:{media_id}|Kirschenstand]]\n\n" "Danach der Hafen.\n\n" f"[[media:{video_id}|Steg]]" ), "origin": "user_edit", }, ) expect(embedded.status_code == 200, "embedded body saved") expect(f"![Kirschenstand](kansho-media:{media_id})" in embedded.json()["body"], "image token stored as markdown") expect(f"![Steg](kansho-media:{video_id})" in embedded.json()["body"], "video token stored as markdown") style_after = client.get("/api/journal/writing-profile", headers=headers).json() brief = style_after.get("compiled_brief") or "" sources_blob = " ".join(item.get("body") or "" for item in style_after.get("sources") or []) expect(media_id not in brief and video_id not in brief, "asset ids stay out of writing brief") expect(media_id not in sources_blob, "asset ids stay out of style sources") expect("Kirschenstand" in brief or "Kirschenstand" in sources_blob, "caption remains as wording") scratch_mark = "SCRATCHTOKEN-NEVER-EGRESS-xyz" scratch = client.patch( f"/api/journal/days/{day_id}/scratch", headers=headers, json={"items": [{"text": scratch_mark, "done": False}]}, ) expect(scratch.status_code == 200 and scratch.json()["scratch"][0]["text"] == scratch_mark, "scratch saved") hidden_scratch = client.patch( f"/api/journal/days/{day_id}/scratch", headers=other_headers, json={"items": [{"text": "fremd", "done": False}]}, ) expect(hidden_scratch.status_code == 404, "scratch isolated") day_after = client.get(f"/api/journal/days/{day_id}", headers=headers).json() expect(any(item["text"] == scratch_mark for item in day_after.get("scratch") or []), "scratch on day payload") reset_debug() turn_scratch = client.post( f"/api/journal/conversations/{conv['id']}/turn", headers=headers, json={"body": "Noch ein Satz zum Abend."}, ) expect(turn_scratch.status_code == 200, "turn after scratch") blob = (turn_scratch.json().get("trace") or {}).get("intern") or "" intern = blob egress = (turn_scratch.json().get("trace") or {}).get("egress") or "" expect(scratch_mark not in blob and scratch_mark not in intern and scratch_mark not in egress, "scratch not in dialogue egress") reset_debug() gen_scratch = client.post( f"/api/journal/days/{day_id}/generate", headers=headers, json={"conversation_ids": [conv["id"]]}, ) expect(gen_scratch.status_code == 200, "generate after scratch") gen_trace = gen_scratch.json().get("trace") or {} gen_blob = " ".join( str(stage.get(key) or "") for stage in (gen_trace.get("stages") or [gen_trace]) for key in ("intern", "egress", "mask_input", "raw", "reply") ) gen_intern = gen_trace.get("intern") or "" expect(scratch_mark not in gen_blob and scratch_mark not in gen_intern, "scratch not in generate egress") from media_store import strip_jpeg_exif jpeg = bytearray(b"\xff\xd8\xff\xe1\x00\x10Exif\x00\x00AAAA\xff\xda\x00\x08\x01\x01\x00\x00\xff\xd9") stripped = strip_jpeg_exif(bytes(jpeg)) expect(b"Exif" not in stripped, "jpeg exif stripped") print("All MVP journal tests passed.") finally: client.__exit__(None, None, None) if __name__ == "__main__": main()