"""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)) from tests.harness import configure_test_engine configure_test_engine() os.environ["KANSHO_FAKE_PROVIDER"] = "1" os.environ["KANSHO_FAKE_DETECT"] = "1" 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"] == "add", "create maps to add") external = parse_result({ "kind": KIND_RESULT, "format_version": 1, "changes": [{ "layer": "trait", "slug": "dry_humor", "action": "add", "proposed_value": "trocken", "evidence_basis": ["external_chat_history"], "exemplars": [{"excerpt": "haha das war irgendwie lustig", "source_id": "invented", "evidence_basis": ["external_chat_history"]}], }], }) expect(external["changes"][0]["evidence_basis"] == ["external_chat_history"], "external basis kept") expect(external["changes"][0]["exemplars"][0].get("source_id") in (None, ""), "external chat does not invent kansho source ids") from_proposal = parse_result({ "kind": KIND_RESULT, "format_version": 1, "mode": "initial_build", "profile": { "traits": [{ "slug": "scene_cuts", "facet_key": "autobiographical_journal", "statement": "wechselt ohne Ankündigung zwischen Szenen", "evidence_basis": ["external_chat_history"], "exemplars": [{"excerpt": "Danach der Hafen.", "source_id": "invented", "evidence_basis": ["external_chat_history"]}], }] }, }) expect(from_proposal["changes"][0]["slug"] == "scene_cuts", "full profile proposal becomes changes") expect(from_proposal["changes"][0]["exemplars"][0].get("source_id") in (None, ""), "proposal from external chat has no kansho source id") dayone = parse_result({ "kind": KIND_RESULT, "format_version": 1, "mode": "initial_build", "core": {"summary": "direkte Erlebnisrekonstruktion", "generalizability": "nur Journal"}, "facets": [{"key": "autobiographical_journal", "summary": "zeitliche Feinauflösung"}], "traits": [{ "slug": "mikrochronologische-kausalrekonstruktion", "label": "Mikrochronologische Rekonstruktion", "scope": "core", "facet_key": "core", "statement": "Erlebnisse in ihrer tatsächlichen Reihenfolge.", "evidence_basis": ["dayone_export"], "exemplars": [{ "excerpt": "Das leichte Kribbeln in der Hand veranlasste mich einen Krankenwagen zu rufen.", "occurred_at": "2024-10-04", "evidence_basis": "dayone_export", }], }], }) slugs = {item["slug"] for item in dayone["changes"]} expect("core" in slugs or any(item["layer"] == "core" for item in dayone["changes"]), "top-level core becomes a change") expect(any(item["layer"] == "facet" for item in dayone["changes"]), "top-level facet becomes a change") expect("mikrochronologische_kausalrekonstruktion" in slugs, "hyphen slugs are coerced") trait = next(item for item in dayone["changes"] if item["layer"] == "trait") expect(trait["evidence_basis"] == ["external_context"], "dayone export is external context") expect(trait["exemplars"][0].get("source_id") in (None, ""), "dayone exemplars have no kansho source id") expect(trait["proposed_value"].startswith("Erlebnisse"), "trait statement is kept") 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") last_long = long_turn.json().get("trace") or {} expect(last_long.get("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 analysis 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 "") + (paste.json().get("prompt") 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("external_chat_history" in instruction, "external chat history is named as provenance") expect("Wenn du aus dem bisherigen Verlauf dieses Chats bereits weitere authentische Texte" in instruction, "paste prompt may use existing chat knowledge") expect("Expliziter Initial Profile Build" not in instruction, "trigger meta is not style evidence") expect("Explizite Nutzer-Review" not in (paste.json().get("prompt") or ""), "ui trigger text is not in prompt") expect(package.get("compiled_brief_excluded") is True, "compiled brief is not the package") expect("kansho.profile_analysis_result" in (paste.json().get("prompt") or ""), "copy prompt names the result contract") expect("profile" in (package.get("expected_result") or {}), "initial_build expects a full proposal") 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": "add", "label": "Trockener Humor", "proposed_value": "gelegentlich trocken, aus Paste-Review", "rationale": "Journal-Evidenz, kein globaler Core.", "evidence_basis": ["kansho_sources"], "evidence_ids": [evidence_id] if evidence_id else [], "exemplars": [{"excerpt": "haha das war irgendwie lustig", "occurred_at": "2024-08-11", "evidence_basis": ["kansho_sources"]}], } ], }, }, ) expect(imported.status_code == 200, f"import result {imported.text}") expect(imported.json()["applied"] == 0, "import does not apply the current profile") expect(imported.json().get("accepted") is False, "proposal waits for user review") expect("dry_humor" not in trait_map(imported.json()["profile"]), "proposal is not auto-merged") accepted = client.post( "/api/journal/writing-profile/review/accept", headers=headers, json={"review_id": imported.json()["review_id"], "baseline": True}, ) expect(accepted.status_code == 200, f"accept baseline {accepted.text}") expect(accepted.json()["applied"] >= 1, "baseline applies accepted traits") profile = accepted.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") review_paste = client.post("/api/journal/writing-profile/review/paste", headers=headers) expect(review_paste.status_code == 200, f"review paste {review_paste.text}") review_pkg = review_paste.json()["package"] expect(review_pkg["mode"] == "review", "confirmed profile opens incremental review") expect("nicht bei jeder Review ein komplett neues Profil" in ((review_pkg.get("instruction") or "") + (review_paste.json().get("prompt") or "")), "review stays incremental") expect(review_pkg["profile"].get("traits"), "review package includes existing traits") expect("profile" not in (review_pkg.get("expected_result") or {}), "review result is incremental") from writing_profile_store import compile_task_brief task_brief = compile_task_brief(profile["profile_id"], "journal_generate") expect("trocken" in task_brief, "journal generate uses relevant traits") expect("Tee auf dem Balkon" not in task_brief, "task brief does not dump historical full texts") two_changes = client.post( "/api/journal/writing-profile/review/import", headers=headers, json={ "review_id": review_paste.json()["id"], "result": { "kind": KIND_RESULT, "format_version": 1, "mode": "review", "changes": [ { "layer": "trait", "slug": "dry_humor", "action": "update", "proposed_value": "sollte einzeln ablehnbar bleiben", "evidence_basis": ["kansho_sources"], }, { "layer": "trait", "slug": "scene_cuts", "facet_key": "autobiographical_journal", "action": "add", "proposed_value": "wechselt ohne Ankündigung zwischen Szenen", "evidence_basis": ["external_chat_history"], "exemplars": [{"excerpt": "Danach der Hafen.", "evidence_basis": ["external_chat_history"]}], }, ], }, }, ) expect(two_changes.status_code == 200, f"review import {two_changes.text}") expect(two_changes.json()["applied"] == 0, "review import stays a proposal") accepted_one = client.post( "/api/journal/writing-profile/review/accept", headers=headers, json={"review_id": two_changes.json()["review_id"], "indexes": [1]}, ) expect(accepted_one.status_code == 200, f"accept one {accepted_one.text}") expect("scene_cuts" in trait_map(accepted_one.json()["profile"]), "accepted change is stored") expect( trait_map(accepted_one.json()["profile"])["dry_humor"]["statement"].startswith("gelegentlich trocken"), "unaccepted change is not applied", ) expect(len((accepted_one.json().get("proposal") or {}).get("changes") or []) == 1, "remaining proposal stays") rejected_one = client.post( "/api/journal/writing-profile/review/reject", headers=headers, json={"review_id": accepted_one.json()["review_id"], "indexes": [0]}, ) expect(rejected_one.status_code == 200, f"reject remaining {rejected_one.text}") expect( trait_map(client.get("/api/journal/writing-profile", headers=headers).json())["dry_humor"]["statement"].startswith("gelegentlich trocken"), "rejected change never reaches the current profile", ) 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}") api_trace = api_review.json().get("trace") or {} expect(api_trace.get("purpose") == "profile_review", "api review uses dedicated purpose") expect(api_trace.get("layer") == "profilreview", "trace is not a dialogue turn") expect(api_review.json()["applied"] == 0, "api result is a proposal, not an auto-apply") 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 import stays a proposal") expect(trait_map(client.get("/api/journal/writing-profile", headers=headers).json())["dry_humor"]["statement"] == humor_before, "frozen keeps trait") brief_as_profile = client.post( "/api/journal/writing-profile/review/import", headers=headers, json={"result": {"kind": KIND_RESULT, "format_version": 1, "compiled_brief": "Das ist nur der Brief."}}, ) expect(brief_as_profile.status_code == 400, "compiled brief is not an import format") empty_result = client.post( "/api/journal/writing-profile/review/import", headers=headers, json={"result": {"kind": KIND_RESULT, "format_version": 1, "mode": "review"}}, ) expect(empty_result.status_code == 400, "empty analysis result is rejected") 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()