"""Transitional learning detect: senses from dialogue review, admin mode switch. Fake detect/provider only. Run from backend/: python tests/test_detect_learning.py """ from __future__ import annotations import os import sys from pathlib import Path from unittest.mock import patch 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 detect_learning import ( cue_for_mention, excerpt_view, get_cue_decision, get_detect_operating_mode, get_sense, record_sense, ) from dialogue_turn import visible_for_role from identity_store import list_confirmed_identities from main import app 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 status_ok(response, label: str) -> None: if response.status_code != 200: detail = response.json() if response.headers.get("content-type", "").startswith("application/json") else {} code = detail.get("detail", {}).get("code") if isinstance(detail, dict) else None expect(False, f"{label} HTTP {response.status_code} {code or ''}".strip()) expect(True, label) def header(token: str) -> dict: return {"X-Auth-Token": token} def open_conv(client: TestClient, headers: dict) -> str: space = client.post("/api/journal/spaces", headers=headers, json={"title": "Alltag"}) day = client.post( f"/api/journal/spaces/{space.json()['id']}/days", headers=headers, json={"calendar_date": "2026-09-08"}, ) conv = client.post( f"/api/journal/days/{day.json()['day']['id']}/conversations", headers=headers, json={"title": "Gespräch"}, ) return conv.json()["id"] 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"}, ) headers = header(setup.json()["token"]) profile_id = setup.json()["profile_id"] providers = client.get("/api/admin/providers", headers=headers) expect(providers.status_code == 200, "providers status") expect(providers.json()["detect_operating_mode"] == "semantic", "default detect mode is semantic") expect(get_detect_operating_mode() == "semantic", "store default is semantic") bad = client.put("/api/admin/providers/detect-mode", headers=headers, json={"mode": "wordlist"}) expect(bad.status_code == 400, "invalid detect mode is rejected") conv_semantic = open_conv(client, headers) semantic_turn = client.post( f"/api/journal/conversations/{conv_semantic}/turn", headers=headers, json={"body": "Anna kam vorbei."}, ) status_ok(semantic_turn, "semantic turn") expect(not semantic_turn.json().get("pending_mask_review"), "semantic mode does not pause for review") expect(any(item.get("role") == "assistant" for item in semantic_turn.json().get("messages") or []), "semantic turn gets an assistant") switched = client.put("/api/admin/providers/detect-mode", headers=headers, json={"mode": "learning"}) status_ok(switched, "switch to learning") expect(switched.json()["detect_operating_mode"] == "learning", "learning mode persisted") conv = open_conv(client, headers) paused = client.post( f"/api/journal/conversations/{conv}/turn", headers=headers, json={"body": "Anna kam vorbei."}, ) status_ok(paused, "learning pause") review = paused.json().get("pending_mask_review") or {} expect(bool(review.get("id")), "learning mode returns a review id") expect(paused.json().get("assistant") is None, "paused turn has no assistant") candidates = review.get("candidates") or [] anna = next((item for item in candidates if item.get("text") == "Anna"), None) expect(anna is not None, "Anna is a review candidate") hidden = visible_for_role({"pending_mask_review": review, "trace": {"egress": "x"}}, "user") expect("pending_mask_review" in hidden, "users still receive the mask review") expect("trace" not in hidden, "users do not receive the detect trace") reloaded = client.get(f"/api/journal/conversations/{conv}", headers=headers) expect(reloaded.json().get("pending_mask_review", {}).get("id") == review["id"], "pending review survives reload") missing = client.post( f"/api/journal/conversations/{conv}/turn/review", headers=headers, json={"review_id": "missing", "decisions": []}, ) expect(missing.status_code == 404, "unknown review is gone") identity = client.post( f"/api/journal/conversations/{conv}/turn/review", headers=headers, json={ "review_id": review["id"], "decisions": [{"id": anna["id"], "decision": "identity"}], }, ) status_ok(identity, "identity review") expect(not identity.json().get("pending_mask_review"), "review continue clears the pause") expect(any(item.get("role") == "assistant" for item in identity.json().get("messages") or []), "continue produces an assistant") confirmed = list_confirmed_identities(profile_id) expect(any((item.get("canonical_label") or "").casefold() == "anna" for item in confirmed), "identity review confirms the registry") sense = get_sense(profile_id, "Anna") expect(sense["identity_hits"] >= 1 and not sense["ambiguous"], "Anna is identity-only") second = client.post( f"/api/journal/conversations/{conv}/turn", headers=headers, json={"body": "Anna hat später angerufen."}, ) status_ok(second, "second Anna turn") expect(not second.json().get("pending_mask_review"), "identity-only spelling skips the popup") food_conv = open_conv(client, headers) food = client.post( f"/api/journal/conversations/{food_conv}/turn", headers=headers, json={"body": "Hanna aß mit uns."}, ) status_ok(food, "hanna pause") food_review = food.json().get("pending_mask_review") or {} food_candidates = food_review.get("candidates") or [] hanna = next((item for item in food_candidates if item.get("text") == "Hanna"), None) expect(hanna is not None, "Hanna is a review candidate") declined = client.post( f"/api/journal/conversations/{food_conv}/turn/review", headers=headers, json={ "review_id": food_review["id"], "decisions": [{"id": hanna["id"], "decision": "not_identity"}], }, ) status_ok(declined, "not_identity review") expect(not any((item.get("canonical_label") or "").casefold() == "hanna" for item in list_confirmed_identities(profile_id)), "not_identity does not confirm Hanna") expect(get_sense(profile_id, "Hanna")["non_identity_hits"] >= 1, "not_identity records a sense") hanna_again = client.post( f"/api/journal/conversations/{food_conv}/turn", headers=headers, json={"body": "Hanna aß mit uns."}, ) status_ok(hanna_again, "second Hanna turn") expect(not hanna_again.json().get("pending_mask_review"), "known non-identity skips the popup") record_sense(profile_id, "Clarissa", identity=True) record_sense(profile_id, "Clarissa", identity=False) expect(get_sense(profile_id, "Clarissa")["ambiguous"], "both senses mark Clarissa as ambiguous") amb_conv = open_conv(client, headers) with patch("detect_learning.try_local_passage_decision", return_value="not_identity"): amb = client.post( f"/api/journal/conversations/{amb_conv}/turn", headers=headers, json={"body": "Clarissa lag auf dem Tisch."}, ) status_ok(amb, "ambiguous local passage") expect(not amb.json().get("pending_mask_review"), "local passage can finish without a popup") expect(any(item.get("role") == "assistant" for item in amb.json().get("messages") or []), "local passage still generates") kin_conv = open_conv(client, headers) kin = client.post( f"/api/journal/conversations/{kin_conv}/turn", headers=headers, json={"body": "Ich war mit meinem Sohn Leon im Park."}, ) status_ok(kin, "kinship Leon pause") kin_review = kin.json().get("pending_mask_review") or {} kin_names = [item.get("text") for item in kin_review.get("candidates") or []] expect("Leon" in kin_names, "Sohn Leon is offered even when Detect does not report it") leon = next((item for item in kin_review.get("candidates") or [] if item.get("text") == "Leon"), None) expect(leon is not None, "Leon candidate id is present") kin_done = client.post( f"/api/journal/conversations/{kin_conv}/turn/review", headers=headers, json={ "review_id": kin_review["id"], "decisions": [{"id": leon["id"], "decision": "identity"}], }, ) status_ok(kin_done, "Leon identity review") kin_again = client.post( f"/api/journal/conversations/{kin_conv}/turn", headers=headers, json={"body": "Ich war mit meinem Sohn Leon im Park."}, ) status_ok(kin_again, "second Leon turn") expect(not kin_again.json().get("pending_mask_review"), "confirmed Sohn Leon skips the popup") sushi_conv = open_conv(client, headers) sushi = client.post( f"/api/journal/conversations/{sushi_conv}/turn", headers=headers, json={ "body": ( "Gestern bin ich mit meiner Frau Sushi und meinem Sohn Rohan " "Sushi essen gegangen. Das Restaurant hat mich dabei total beeindruckt." ) }, ) status_ok(sushi, "homonym sentence pause") sushi_names = [item.get("text") for item in (sushi.json().get("pending_mask_review") or {}).get("candidates") or []] expect("Rohan" in sushi_names, "Rohan from Sohn is a candidate") expect("Sushi" in sushi_names, "Sushi from Frau is a candidate") expect("Restaurant" not in sushi_names, "plain Restaurant is not kinship-offered") sushi_hits = [ item for item in (sushi.json().get("pending_mask_review") or {}).get("candidates") or [] if item.get("text") == "Sushi" ] expect(len(sushi_hits) >= 2, "both Sushi mentions are offered") expect( sushi_hits[0].get("highlight_start") != sushi_hits[1].get("highlight_start"), "the two Sushi mentions highlight different offsets", ) for item in sushi_hits: excerpt = item.get("excerpt") or "" start = item.get("highlight_start") end = item.get("highlight_end") expect(excerpt[start:end] == "Sushi", "highlight covers the Sushi token") homonym = ( "Gestern bin ich mit meiner Frau Sushi und meinem Sohn Rohan " "Sushi essen gegangen." ) first_at = homonym.index("Sushi") second_at = homonym.rindex("Sushi") first = excerpt_view(homonym, first_at, first_at + 5, "Sushi") second = excerpt_view(homonym, second_at, second_at + 5, "Sushi") expect(first["highlight_start"] < second["highlight_start"], "person Sushi sits left of dish Sushi") expect(first["excerpt"][first["highlight_start"]:first["highlight_end"]] == "Sushi", "first highlight is Sushi") expect(second["excerpt"][second["highlight_start"]:second["highlight_end"]] == "Sushi", "second highlight is Sushi") prefix = "SYSTEM\n" mapped = excerpt_view( homonym, len(prefix) + second_at, len(prefix) + second_at + 5, "Sushi", prefix + homonym, ) expect(mapped["highlight_start"] == second["highlight_start"], "rendered detect offsets map to the dish Sushi") expect(cue_for_mention(homonym, first_at, first_at + 5, "Sushi") == "frau", "person Sushi attaches to Frau") expect(cue_for_mention(homonym, second_at, second_at + 5, "Sushi") == "rohan", "dish Sushi stays distinct from Frau") appos = "Sushi, meine Frau war erkältet." appos_at = appos.index("Sushi") expect(cue_for_mention(appos, appos_at, appos_at + 5, "Sushi") == "frau", "apposition Sushi, meine Frau binds Frau") far = "Meine Frau und ich aßen Sushi." far_at = far.rindex("Sushi") expect(cue_for_mention(far, far_at, far_at + 5, "Sushi") != "frau", "Frau elsewhere in the sentence does not bind the dish") sushi_review = sushi.json().get("pending_mask_review") or {} sushi_decisions = [] for item in sushi_review.get("candidates") or []: label = item.get("text") cue = item.get("cue") if label == "Sushi" and cue != "frau": sushi_decisions.append({"id": item["id"], "decision": "not_identity"}) else: sushi_decisions.append({"id": item["id"], "decision": "identity"}) sushi_done = client.post( f"/api/journal/conversations/{sushi_conv}/turn/review", headers=headers, json={"review_id": sushi_review["id"], "decisions": sushi_decisions}, ) status_ok(sushi_done, "homonym review") expect(get_cue_decision(profile_id, "Sushi", "frau") == "identity", "Frau Sushi is stored as identity") expect(get_cue_decision(profile_id, "Sushi", "rohan") == "not_identity", "dish Sushi after Rohan is stored") expect(get_cue_decision(profile_id, "Rohan", "sohn") == "identity", "Sohn Rohan cue is stored") sushi_again = client.post( f"/api/journal/conversations/{sushi_conv}/turn", headers=headers, json={ "body": ( "Gestern bin ich mit meiner Frau Sushi und meinem Sohn Rohan " "Sushi essen gegangen. Das Restaurant hat mich dabei total beeindruckt." ) }, ) status_ok(sushi_again, "repeat homonym sentence") expect(not sushi_again.json().get("pending_mask_review"), "same reviewed sentence does not re-ask") back = client.put("/api/admin/providers/detect-mode", headers=headers, json={"mode": "semantic"}) expect(back.json()["detect_operating_mode"] == "semantic", "mode can return to semantic") if __name__ == "__main__": main()