Kansho/backend/tests/test_journal_opening.py
Lars ec0f8bad55 Keep journal openings from treating finished days as open plans.
A 400-character entry prefix plus plan regex could reopen a completed yesterday as if it were still pending. Openings now take plans only from yesterday's user dialogue, never from saved entries, and skip space recency in the first call.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 08:25:00 +02:00

307 lines
16 KiB
Python

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