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>
307 lines
16 KiB
Python
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()
|