Kansho/backend/tests/test_mvp_journal.py

758 lines
38 KiB
Python
Raw Permalink Normal View History

2026-08-25 13:57:23 +02:00
"""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))
2026-08-26 10:42:20 +02:00
os.environ["KANSHO_MEDIA_ROOT"] = str(Path(tempfile.gettempdir()) / "kansho-mvp-journal-media")
2026-08-25 13:57:23 +02:00
os.environ["KANSHO_FAKE_PROVIDER"] = "1"
os.environ["KANSHO_FAKE_DETECT"] = "1"
2026-08-26 10:42:20 +02:00
Path(os.environ["KANSHO_MEDIA_ROOT"]).mkdir(parents=True, exist_ok=True)
2026-08-25 13:57:23 +02:00
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")
2026-08-25 13:57:23 +02:00
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")
2026-08-25 13:57:23 +02:00
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)
2026-08-26 10:42:20 +02:00
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")
2026-08-25 13:57:23 +02:00
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")
2026-08-26 10:42:20 +02:00
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")
2026-08-26 10:42:20 +02:00
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")
2026-08-26 10:42:20 +02:00
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")
2026-08-26 10:42:20 +02:00
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")
2026-08-25 13:57:23 +02:00
expect(
"WRITING_PROFILE" in narrate_intern or "Neutraler Journalstil" in narrate_intern or "Core:" in narrate_intern,
"A style brief reaches generate",
)
2026-08-29 20:28:59 +02:00
expect("STYLE_EXAMPLES" not in narrate_intern, "A default voice does not attach a style-examples block")
2026-08-28 08:40:28 +02:00
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,
2026-08-25 13:57:23 +02:00
"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")
2026-08-25 13:57:23 +02:00
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.",
)
2026-08-26 10:42:20 +02:00
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")
2026-08-25 13:57:23 +02:00
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",
)
2026-08-26 10:42:20 +02:00
intern_e = (turn_e.get("trace") or {}).get("intern") or ""
expect("Motivationen erfinden" in intern_e, "E no invented emotion rule")
2026-08-25 13:57:23 +02:00
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")
2026-08-26 10:42:20 +02:00
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 "")
2026-08-29 20:28:59 +02:00
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")
2026-08-25 13:57:23 +02:00
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",
)
2026-08-26 10:42:20 +02:00
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]
2026-08-26 10:42:20 +02:00
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()
2026-08-25 13:57:23 +02:00
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")
2026-08-26 10:42:20 +02:00
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")
2026-08-25 13:57:23 +02:00
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")
2026-08-26 10:42:20 +02:00
gen_trace = gen_scratch.json().get("trace") or {}
2026-08-25 13:57:23 +02:00
gen_blob = " ".join(
2026-08-26 10:42:20 +02:00
str(stage.get(key) or "")
for stage in (gen_trace.get("stages") or [gen_trace])
for key in ("intern", "egress", "mask_input", "raw", "reply")
2026-08-25 13:57:23 +02:00
)
2026-08-26 10:42:20 +02:00
gen_intern = gen_trace.get("intern") or ""
2026-08-25 13:57:23 +02:00
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()