Kansho/backend/tests/test_profile_review.py
2026-08-25 13:57:23 +02:00

428 lines
20 KiB
Python

"""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()