Senses grow from confirmations instead of a word list, so a later local pipeline can decide homonyms on a short passage. Default stays semantic. Local detect waits longer, and the reverse-proxy timeout is documented so Dev does not 504 first. Co-authored-by: Cursor <cursoragent@cursor.com>
185 lines
8.2 KiB
Python
185 lines
8.2 KiB
Python
"""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 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")
|
|
|
|
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")
|
|
|
|
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()
|