Kansho/backend/tests/test_mvp_journal.py
2026-08-28 08:40:28 +02:00

760 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""MVP journal acceptance cases AK. 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))
os.environ["KANSHO_DB_PATH"] = str(Path(tempfile.gettempdir()) / "kansho-mvp-journal-test.sqlite")
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_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 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" in narrate_intern, "A style examples block is labeled")
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" in intern_i, "I style examples reach generate")
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()