290 lines
14 KiB
Python
290 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_DB_PATH"] = str(Path(tempfile.gettempdir()) / "kansho-profile-governance-test.sqlite")
|
|
os.environ["KANSHO_FAKE_PROVIDER"] = "1"
|
|
os.environ["KANSHO_FAKE_DETECT"] = "1"
|
|
Path(os.environ["KANSHO_DB_PATH"]).unlink(missing_ok=True)
|
|
|
|
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")
|
|
|
|
from privacy_gateway import debug_last
|
|
|
|
expect("Produktdefault" in (debug_last["rendered"] if debug_last else ""), "hint reaches dialogue prompt")
|
|
expect("Ein bis drei Sätze" in (debug_last["rendered"] if debug_last else ""), "length default is in hint not anonymous")
|
|
expect("anonyme Produktregel" in (debug_last["rendered"] if debug_last else ""), "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")
|
|
from privacy_gateway import debug_last as last_hint
|
|
|
|
expect("Meist nur ein Satz" in last_hint["rendered"], "personal preference reaches hint")
|
|
expect("persönlich" in last_hint["rendered"], "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()
|