Kansho/backend/tests/test_profile_governance.py
Lars 9d79e3e92f
Some checks failed
Deploy Development / deploy (push) Successful in 51s
Test Suite / pytest-backend (push) Failing after 2m32s
Test Suite / smoke-dev (push) Successful in 0s
Test Suite / frontend-build (push) Successful in 15s
Run backend tests with pytest after Dev deploy.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 14:47:04 +02:00

288 lines
14 KiB
Python

"""Writing/Interaction profile governance. Run from backend/: python tests/test_profile_governance.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_FAKE_PROVIDER"] = "1"
os.environ["KANSHO_FAKE_DETECT"] = "1"
from fastapi.testclient import TestClient
from main import app
from interaction_profile_store import propose_observation
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}
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 facet_map(profile: dict) -> dict:
return {item["facet_key"]: item for item in profile.get("facets") or []}
def trait_map(profile: dict) -> dict:
return {item["slug"]: item for item in profile.get("traits") or []}
def main() -> None:
reset_debug()
with TestClient(app) as client:
setup = client.post(
"/api/auth/setup",
json={"email": "lars@example.test", "name": "Lars", "password": "test-pass"},
)
expect(setup.status_code == 200, "setup")
headers = header(setup.json()["token"])
profile_id = setup.json()["profile_id"]
_space, day = open_space_day(client, headers)
day_id = day["day"]["id"]
saved = client.post(
"/api/journal/entries",
headers=headers,
json={
"journal_day_id": day_id,
"title": "Markt",
"body": "Heute um 7:30 Uhr bin ich zum Markt. Danach habe ich Kirschen gekauft.",
"origin": "user_edit",
},
)
expect(saved.status_code == 200, f"save {saved.text}")
writing = client.get("/api/journal/writing-profile", headers=headers).json()
expect(writing["governance"] == "learning", "default writing governance is learning")
expect(writing.get("lifecycle") == "uninitialized", "save does not confirm the profile")
expect(any(item["kind"] == "journal_entry" for item in writing["sources"]), "saved entry is a source")
expect("Finale Journal Entries" in (writing.get("compiled_brief") or ""), "brief lists saved entries")
expect("Kirschen" in (writing.get("compiled_brief") or ""), "entry wording reaches brief")
expect("rhythm" not in facet_map(writing), "heuristics do not persist as facets")
expect(not trait_map(writing), "save does not invent semantic traits")
locked = client.patch(
"/api/journal/writing-profile/facets/rhythm",
headers=headers,
json={"locked": True},
)
expect(locked.status_code == 200, f"lock {locked.text}")
expect("rhythm" in trait_map(locked.json()), "legacy style key becomes a trait")
expect(trait_map(locked.json())["rhythm"]["locked"] in {True, 1}, "rhythm locked")
other = client.post(
"/api/journal/entries",
headers=headers,
json={
"journal_day_id": day_id,
"title": "Kurz",
"body": "Ok. Gut. Fertig. Punkt. Schluss. Ende. Still. Knapp.",
"origin": "user_edit",
},
)
expect(other.status_code == 200, "second entry")
after_second = client.get("/api/journal/writing-profile", headers=headers).json()
expect(after_second.get("lifecycle") == "uninitialized", "more entries still do not confirm")
expect(trait_map(after_second)["rhythm"]["locked"] in {True, 1}, "locked trait survives later saves")
client.patch(
"/api/journal/writing-profile/facets/rhythm",
headers=headers,
json={"locked": False},
)
frozen = client.patch(
"/api/journal/writing-profile",
headers=headers,
json={"governance": "frozen"},
)
expect(frozen.json()["governance"] == "frozen", "frozen set")
rhythm_frozen = trait_map(frozen.json())["rhythm"].get("statement") or ""
client.post(
"/api/journal/entries",
headers=headers,
json={
"journal_day_id": day_id,
"title": "Anders",
"body": (
"Vielleicht bedeutet das weniger, als es scheint. Allerdings wunderte ich mich. "
"Im Grunde war der Tag merkwürdig und irgendwie offen."
),
"origin": "user_edit",
},
)
still_frozen = client.get("/api/journal/writing-profile", headers=headers).json()
expect(
(trait_map(still_frozen)["rhythm"].get("statement") or "") == rhythm_frozen,
"frozen keeps trait values",
)
advising = client.patch(
"/api/journal/writing-profile",
headers=headers,
json={"governance": "advising"},
)
expect(advising.json()["governance"] == "advising", "advising set")
imported = client.post(
"/api/journal/writing-profile/import",
headers=headers,
json={
"body": "Haha das war irgendwie lustig. Witzig, wirklich witzig, lol.",
"occurred_at": "2019-07-12",
"context_hint": "vacation_diary",
},
)
expect(imported.status_code == 200, f"import {imported.text}")
expect(any(item["kind"] == "imported_text" for item in imported.json()["sources"]), "import stored")
expect(
int(imported.json().get("pending_evidence") or 0) == 0,
"unconfirmed profile does not start continuous review",
)
vacation = next(item for item in imported.json()["sources"] if item["kind"] == "imported_text")
expect((vacation.get("occurred_at") or "").startswith("2019-07-12"), "import keeps time")
expect(vacation.get("context_hint") == "autobiographical_journal", "vacation diary maps to journal facet")
client.patch("/api/journal/writing-profile", headers=headers, json={"governance": "learning"})
conv = client.post(f"/api/journal/days/{day_id}/conversations", headers=headers, json={"title": "Chat"})
turn = client.post(
f"/api/journal/conversations/{conv.json()['id']}/turn",
headers=headers,
json={"body": "Nur ein kurzer Satz ohne Gewicht."},
)
expect(turn.status_code == 200, f"turn {turn.text}")
after_turn = client.get("/api/journal/writing-profile", headers=headers).json()
brief = after_turn.get("compiled_brief") or ""
expect("Finale Journal Entries" in brief, "entries remain strongest after later dialogue")
expect("Formulierungen aus dem Dialog" not in brief, "dialogue excerpts drop once entries exist")
expect("Kirschen" in brief, "earlier journal wording remains")
interaction = client.get("/api/journal/interaction-profile", headers=headers).json()
expect(interaction["governance"] == "advising", "interaction default is advising")
expect(interaction["preferences"] == [], "turn does not invent personal preferences")
expect(interaction["suggestions"] == [], "silence is not an interaction suggestion")
expect(any(item["pref_key"] == "reply_length" for item in interaction["defaults"]), "labeled product defaults")
intern = (turn.json().get("trace") or {}).get("intern") or ""
expect("Produktdefault" in intern, "hint reaches dialogue prompt")
expect("Ein bis drei Sätze" in intern, "length default is in hint not anonymous")
expect("anonyme Produktregel" in intern, "prompt labels the moved preference")
proposed = propose_observation(
profile_id,
scope="autobiographical",
pref_key="listening",
proposed_value="Bei längeren Erzählungen zunächst stärker zuhören.",
evidence="Beobachtung, kein automatisches Lernen",
)
expect(proposed and proposed["suggestions"], "observation is a suggestion only")
expect(proposed["preferences"] == [], "observation is not auto-applied")
rejected = client.post(
f"/api/journal/interaction-profile/suggestions/{proposed['suggestions'][0]['id']}/reject",
headers=headers,
)
expect(rejected.json()["suggestions"] == [], "rejected observation stays out of the profile")
personal = client.patch(
"/api/journal/interaction-profile/preferences",
headers=headers,
json={
"scope": "global",
"pref_key": "reply_length",
"value": "Meist nur ein Satz.",
"origin": "explicit",
},
)
expect(personal.status_code == 200, f"personal pref {personal.text}")
expect(personal.json()["preferences"][0]["origin"] == "explicit", "explicit origin kept")
reset_debug()
later = client.post(
f"/api/journal/conversations/{conv.json()['id']}/turn",
headers=headers,
json={"body": "Heute Vormittag zum Markt, danach Kirschen, dann noch der Hafen bei 18:00 Uhr."},
)
expect(later.status_code == 200, "second turn")
intern = (later.json().get("trace") or {}).get("intern") or ""
expect("Meist nur ein Satz" in intern, "personal preference reaches hint")
expect("persönlich" in intern, "personal override is labeled")
expect(later.json()["conversation"].get("narrative_mode"), "dialogue state remains automatic")
expect("long_story" in later.json()["conversation"], "long_story is conversation state")
manual = client.patch(
"/api/journal/writing-profile/facets/humor",
headers=headers,
json={"value": "trocken, selten, nie aufgesetzt"},
)
expect("humor" in trait_map(manual.json()), "manual humor is a trait")
expect(trait_map(manual.json())["humor"]["origin"] == "manual", "manual trait origin")
expect("trocken, selten" in (manual.json().get("compiled_brief") or ""), "manual trait reaches brief")
exported = client.get("/api/journal/writing-profile/export", headers=headers).json()
expect(exported["kind"] == "kansho.writing_profile", "writing export kind")
expect(exported["format_version"] == 1, "writing export version")
expect(any(item["slug"] == "humor" and "trocken" in (item.get("statement") or "") for item in exported["traits"]), "export keeps manual trait")
expect("profile_id" not in exported, "export is portable")
expect("sources" not in exported, "export is not a journal backup")
client.patch("/api/journal/writing-profile/facets/humor", headers=headers, json={"value": "wird überschrieben"})
restored = client.post("/api/journal/writing-profile/restore", headers=headers, json=exported)
expect(restored.status_code == 200, f"writing restore {restored.text}")
expect(trait_map(restored.json())["humor"]["statement"].startswith("trocken"), "restore replaces traits")
expect(trait_map(restored.json())["humor"]["origin"] == "manual", "restore keeps origin")
wrong = client.post(
"/api/journal/writing-profile/restore",
headers=headers,
json={"kind": "kansho.interaction_profile", "format_version": 1, "preferences": []},
)
expect(wrong.status_code == 400, "writing restore rejects interaction document")
interaction_doc = client.get("/api/journal/interaction-profile/export", headers=headers).json()
expect(interaction_doc["kind"] == "kansho.interaction_profile", "interaction export kind")
expect(interaction_doc["preferences"][0]["value"] == "Meist nur ein Satz.", "interaction export keeps personal pref")
client.patch(
"/api/journal/interaction-profile/preferences",
headers=headers,
json={"scope": "global", "pref_key": "reply_length", "value": "Ganz anders.", "origin": "manual"},
)
interaction_back = client.post("/api/journal/interaction-profile/restore", headers=headers, json=interaction_doc)
expect(interaction_back.json()["preferences"][0]["value"] == "Meist nur ein Satz.", "interaction restore replaces prefs")
bundle = client.get("/api/journal/profiles/export", headers=headers).json()
expect(bundle["kind"] == "kansho.profiles", "bundle kind")
bundle["writing"]["traits"] = [
item if item["slug"] != "humor" else {**item, "statement": "aus dem Sammeldokument", "locked": True}
for item in bundle["writing"]["traits"]
]
both = client.post("/api/journal/profiles/restore", headers=headers, json=bundle)
expect(both.status_code == 200, f"bundle restore {both.text}")
expect(trait_map(both.json()["writing"])["humor"]["statement"] == "aus dem Sammeldokument", "bundle restores writing")
expect(trait_map(both.json()["writing"])["humor"]["locked"] in {True, 1}, "bundle restores lock")
expect(both.json()["interaction"]["preferences"][0]["value"] == "Meist nur ein Satz.", "bundle restores interaction")
print("profile governance ok")
if __name__ == "__main__":
main()