"""Cost-conscious profile review. Run from backend/: python tests/test_profile_review.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-review-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 privacy_gateway import reset_debug from profile_review import ( KIND_PACKAGE, KIND_RESULT, facet_delta, is_strong_edit, parse_result, ) 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 trait_map(profile: dict) -> dict: return {item["slug"]: item for item in profile.get("traits") or []} def facet_map(profile: dict) -> dict: return {item["facet_key"]: item for item in profile.get("facets") or []} def pending_triggers(status: dict) -> set[str]: return {item.get("trigger") for item in status.get("pending_evidence") or []} def main() -> None: expect(not is_strong_edit("Heute Kirschen am Markt gekauft.", "Heute Kirschen am Markt gekauft."), "same text is not a strong edit") expect( is_strong_edit( "Heute war der Markt voll und ich habe Kirschen gekauft.", "Vielleicht bedeutet das weniger. Allerdings wunderte ich mich den ganzen Abend.", ), "divergent rewrite is a strong edit", ) expect(facet_delta({"rhythm": "kurz"}, {"rhythm": "lang", "humor": "trocken"}) == ["rhythm", "humor"], "delta lists changed local signals") parsed_core = parse_result({ "kind": KIND_RESULT, "format_version": 1, "changes": [{"layer": "core", "key": "ignored", "action": "keep", "evidence_ids": []}], }) expect(parsed_core["changes"][0]["key"] == "core", "core layer maps to core key") parsed_create = parse_result({ "kind": KIND_RESULT, "format_version": 1, "changes": [{ "layer": "trait", "slug": "scene_cuts", "facet_key": "autobiographical_journal", "action": "create", "proposed_value": "wechselt ohne Ankündigung zwischen Szenen", "exemplars": [{"excerpt": "Danach der Hafen."}], "evidence_ids": ["e1"], }], }) expect(parsed_create["changes"][0]["slug"] == "scene_cuts", "dynamic trait slugs are accepted") expect(parsed_create["changes"][0]["action"] == "create", "create remains create") 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"]) _space, day = open_space_day(client, headers) day_id = day["day"]["id"] first = 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(first.status_code == 200, "first save") writing = client.get("/api/journal/writing-profile", headers=headers).json() expect(writing.get("lifecycle") == "uninitialized", "first save leaves profile unconfirmed") expect("rhythm" not in {item.get("facet_key") for item in writing.get("facets") or []}, "no heuristic facet bootstrap") expect(not writing.get("traits"), "no inferred traits") status = client.get("/api/journal/writing-profile/review", headers=headers).json() expect(status["pending_evidence"] == [], "first save does not enqueue a review") second = client.post( "/api/journal/entries", headers=headers, json={ "journal_day_id": day_id, "title": "Anders", "body": ( "Haha das war irgendwie lustig, lol. Vielleicht bedeutet das weniger, als es scheint. " "Allerdings wunderte ich mich. Im Grunde war der Abend merkwürdig und offen." ), "origin": "user_edit", }, ) expect(second.status_code == 200, "second save") status = client.get("/api/journal/writing-profile/review", headers=headers).json() expect(status["pending_evidence"] == [], "continuous queue waits for confirmed profile") frozen = client.patch("/api/journal/writing-profile", headers=headers, json={"governance": "frozen"}) expect(frozen.status_code == 200, "frozen") client.post( "/api/journal/entries", headers=headers, json={ "journal_day_id": day_id, "title": "Frozen", "body": "Ganz anderer Ton, witzig, lol, haha, und trotzdem keine Review.", "origin": "user_edit", }, ) status = client.get("/api/journal/writing-profile/review", headers=headers).json() expect(status["pending_evidence"] == [], "unconfirmed frozen still has no queue") client.patch("/api/journal/writing-profile", headers=headers, json={"governance": "learning"}) draft = client.post( "/api/journal/entries", headers=headers, json={ "journal_day_id": day_id, "title": "Draft", "body": ( "Heute war der Markt voll und ich bin früh gegangen und habe Kirschen gekauft " "und danach den Hafen gesehen, ganz ruhig und ohne Eile." ), "origin": "accepted_draft", }, ) expect(draft.status_code == 200, "accepted draft stored") edited = client.post( "/api/journal/entries", headers=headers, json={ "journal_day_id": draft.json()["journal_day_id"] if False else day_id, "entry_id": draft.json()["id"], "title": "Draft", "body": ( "Der Nachmittag kippte. Ich blieb länger als geplant, sprach kaum, " "und schrieb später nur zwei knappe Zeilen ohne Markt und ohne Hafen." ), "origin": "user_edit", }, ) expect(edited.status_code == 200, "strong user edit") status = client.get("/api/journal/writing-profile/review", headers=headers).json() expect("user_edit_of_draft" not in pending_triggers(status), "draft edit waits until the profile is confirmed") conv = client.post(f"/api/journal/days/{day_id}/conversations", headers=headers, json={"title": "Chat"}) long_body = ( "Heute früh bin ich zum Markt gegangen und habe Kirschen gekauft, danach den Hafen gesehen, " "später noch Brot geholt, mit jemandem gesprochen, Tee getrunken, den Wind bemerkt, " "und am Abend den Tag in Ruhe nachvollzogen, ohne etwas zu erzwingen oder nach einem Muster zu suchen. " "Zwischendurch war eine Pause, dann noch ein kurzer Blick aufs Wasser, und zum Schluss bin ich langsam heimgegangen, " "ohne den Faden zu verlieren und ohne aus dem Gesprächsverlauf eine dauerhafte Vorliebe zu machen." ) expect(len(long_body.split()) >= 60, "sample text is long enough") reset_debug() long_turn = client.post( f"/api/journal/conversations/{conv.json()['id']}/turn", headers=headers, json={"body": long_body}, ) expect(long_turn.status_code == 200, "long turn") from privacy_gateway import debug_last as last_long expect(last_long["purpose"] == "dialogue_turn", "long turn still has no review call") status = client.get("/api/journal/writing-profile/review", headers=headers).json() expect("dialogue_sample" not in pending_triggers(status), "dialogue is not sampled before confirm") corpus = client.post( "/api/journal/writing-profile/corpus", headers=headers, json={ "items": [ { "body": "Am ersten Urlaubstag um 6:00 Uhr Tee auf dem Balkon, danach der Hafen. Kirschen vom Markt.", "occurred_at": "2018-07-02", "context_hint": "vacation_diary", }, { "body": "Haha das war irgendwie lustig, lol. Der Abend kippte, ohne dass ich es erklärt hätte.", "occurred_at": "2024-08-11", "context_hint": "vacation_diary", }, { "body": "Vielleicht bedeutet das weniger. Allerdings wunderte ich mich über die Stille nach dem Markt.", "occurred_at": "2025-03-04", "context_hint": "autobiographical_journal", }, ] }, ) expect(corpus.status_code == 200, f"corpus import {corpus.text}") dated = [item for item in corpus.json()["sources"] if item.get("kind") == "imported_text"] expect(len(dated) >= 3, "historical corpus is more than two entries") expect(any((item.get("occurred_at") or "").startswith("2018-07-02") for item in dated), "older source keeps time") expect(any(item.get("recency_role") == "long_term" for item in dated), "older text is long-term evidence") paste = client.post("/api/journal/writing-profile/initial-build/paste", headers=headers) expect(paste.status_code == 200, f"initial paste {paste.text}") package = paste.json()["package"] expect(package["kind"] == KIND_PACKAGE, "paste uses review package contract") expect(package["format_version"] == 1, "package version") expect(package["mode"] == "initial_build", "unconfirmed profile opens an initial build") expect(package["existing_before_new"], "package names Existing-before-New") expect(any(item.get("kind") == "trait_hint" for item in package["seed_catalog"]), "seed catalog is ordering help") expect(len(package.get("corpus") or []) >= 3, "initial package includes historical corpus") expect(any(item.get("occurred_at") for item in package["corpus"]), "corpus items keep time") instruction = package.get("instruction") or "" expect("bestehenden Trait bestätigen" in instruction, "instruction starts with confirm") expect("erst dann einen neuen Trait anlegen" in instruction, "instruction defers create") expect("autobiographical_journal" in instruction, "vacation/journal facet is named") expect("kansho.profile_review_result" in (paste.json().get("prompt") or ""), "copy prompt names the result contract") evidence_id = (package.get("evidences") or [{}])[0].get("id") imported = client.post( "/api/journal/writing-profile/review/import", headers=headers, json={ "review_id": paste.json()["id"], "result": { "kind": KIND_RESULT, "format_version": 1, "target": "writing", "mode": "initial_build", "changes": [ { "layer": "trait", "slug": "dry_humor", "facet_key": "autobiographical_journal", "action": "create", "label": "Trockener Humor", "proposed_value": "gelegentlich trocken, aus Paste-Review", "rationale": "Journal-Evidenz, kein globaler Core.", "evidence_ids": [evidence_id] if evidence_id else [], "exemplars": [{"excerpt": "haha das war irgendwie lustig", "occurred_at": "2024-08-11"}], } ], }, }, ) expect(imported.status_code == 200, f"import result {imported.text}") expect(imported.json()["applied"] >= 1, "initial build applies traits") profile = imported.json()["profile"] expect(profile.get("lifecycle") == "confirmed", "successful initial build confirms the profile") expect(trait_map(profile)["dry_humor"]["statement"].startswith("gelegentlich trocken"), "created trait is stored") expect(trait_map(profile)["dry_humor"]["facet_key"] == "autobiographical_journal", "vacation evidence stays in journal facet") expect(any("haha" in (ref.get("excerpt") or "") for ref in trait_map(profile)["dry_humor"].get("exemplars") or []), "trait keeps exemplar") expect("core" not in facet_map(profile) or not (facet_map(profile).get("core") or {}).get("value"), "single source kind does not invent a global core") drift = client.post( "/api/journal/entries", headers=headers, json={ "journal_day_id": day_id, "title": "Drift", "body": ( "Haha das war irgendwie lustig, lol. Vielleicht bedeutet das weniger, als es scheint. " "Allerdings wunderte ich mich. Im Grunde war der Abend merkwürdig und offen." ), "origin": "user_edit", }, ) expect(drift.status_code == 200, "drift save after confirm") status = client.get("/api/journal/writing-profile/review", headers=headers).json() expect("heuristic_drift" in pending_triggers(status), "after confirm, local heuristic queues evidence") draft_after = client.post( "/api/journal/entries", headers=headers, json={ "journal_day_id": day_id, "title": "Draft nach Confirm", "body": ( "Heute war der Markt voll und ich bin früh gegangen und habe Kirschen gekauft " "und danach den Hafen gesehen, ganz ruhig und ohne Eile." ), "origin": "accepted_draft", }, ) expect(draft_after.status_code == 200, "accepted draft after confirm") edited = client.post( "/api/journal/entries", headers=headers, json={ "journal_day_id": day_id, "entry_id": draft_after.json()["id"], "title": "Draft nach Confirm", "body": ( "Der Nachmittag kippte noch einmal. Ich blieb, sprach kaum, " "und schrieb später nur zwei knappe Zeilen ohne Markt und ohne Hafen und ohne Kirschen." ), "origin": "user_edit", }, ) expect(edited.status_code == 200, "strong user edit after confirm") status = client.get("/api/journal/writing-profile/review", headers=headers).json() expect("user_edit_of_draft" in pending_triggers(status), "strong draft edit is a review trigger") reset_debug() long_turn = client.post( f"/api/journal/conversations/{conv.json()['id']}/turn", headers=headers, json={"body": long_body}, ) expect(long_turn.status_code == 200, "long turn after confirm") expect(long_turn.json()["conversation"].get("preferences") in (None, []), "turn does not write interaction prefs") status = client.get("/api/journal/writing-profile/review", headers=headers).json() expect("dialogue_sample" in pending_triggers(status), "long dialogue may be sampled locally") pending_samples = [item for item in status["pending_evidence"] if item["trigger"] == "dialogue_sample"] second_long = client.post( f"/api/journal/conversations/{conv.json()['id']}/turn", headers=headers, json={"body": long_body + " Noch ein Nachsatz über den Abend und die Kirschen."}, ) expect(second_long.status_code == 200, "second long turn") status = client.get("/api/journal/writing-profile/review", headers=headers).json() samples_now = [item for item in status["pending_evidence"] if item["trigger"] == "dialogue_sample"] expect(len(samples_now) == len(pending_samples), "dialogue sample has a cooldown") interaction = client.get("/api/journal/interaction-profile", headers=headers).json() expect(interaction["preferences"] == [], "dialogue sample is not an interaction preference") expect(interaction["suggestions"] == [], "silence and flow are not interaction learning") client.patch("/api/journal/writing-profile", headers=headers, json={"governance": "advising"}) client.post( "/api/journal/writing-profile/import", headers=headers, json={"body": "Witzig, wirklich witzig, haha das war irgendwie lustig, lol, und trotzdem nur Candidate."}, ) reset_debug() api_review = client.post("/api/journal/writing-profile/review/api", headers=headers) expect(api_review.status_code == 200, f"api review {api_review.text}") from privacy_gateway import debug_last as api_trace expect(api_trace["purpose"] == "profile_review", "api review uses dedicated purpose") expect(api_trace["layer"] == "profilreview", "trace is not a dialogue turn") expect(api_review.json()["suggested"] >= 1, "advising queues review as suggestion") expect( any("trocken" in (item.get("proposed_value") or "") for item in api_review.json()["profile"]["suggestions"]), "api result is a suggestion in advising", ) expect( "aus Paste-Review" in trait_map(api_review.json()["profile"])["dry_humor"]["statement"], "advising does not silently overwrite", ) client.patch("/api/journal/writing-profile", headers=headers, json={"governance": "frozen"}) client.post("/api/journal/writing-profile/review/paste", headers=headers) humor_before = trait_map(client.get("/api/journal/writing-profile", headers=headers).json())["dry_humor"]["statement"] frozen_import = client.post( "/api/journal/writing-profile/review/import", headers=headers, json={ "result": { "kind": KIND_RESULT, "format_version": 1, "changes": [ { "layer": "trait", "slug": "dry_humor", "action": "precisify", "proposed_value": "sollte nicht greifen", "rationale": "frozen", "evidence_ids": [], } ], } }, ) expect(frozen_import.status_code == 200, "frozen import accepted as document") expect(frozen_import.json()["applied"] == 0, "frozen does not apply review") expect(trait_map(frozen_import.json()["profile"])["dry_humor"]["statement"] == humor_before, "frozen keeps trait") bad = client.post( "/api/journal/writing-profile/review/import", headers=headers, json={"result": {"kind": "kansho.writing_profile", "format_version": 1, "changes": []}}, ) expect(bad.status_code == 400, "wrong kind is rejected") print("profile review ok") if __name__ == "__main__": main()