Identity-only senses no longer lock a new cue. Learning stages, journal-generate review, and additive docs follow the closed section 6. Co-authored-by: Cursor <cursoragent@cursor.com>
440 lines
21 KiB
Python
440 lines
21 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 (
|
|
cue_for_mention,
|
|
decision_for_mention,
|
|
excerpt_view,
|
|
get_cue_decision,
|
|
get_detect_operating_mode,
|
|
get_sense,
|
|
merge_gliner_candidates,
|
|
passage_provider,
|
|
record_cue,
|
|
record_sense,
|
|
set_detect_operating_mode,
|
|
try_local_passage_decision,
|
|
)
|
|
from detect_pipeline import (
|
|
MentionSpan,
|
|
compact_diff_counters,
|
|
compare_mentions,
|
|
map_entity_type,
|
|
prepare_detect_pipeline,
|
|
same_mention,
|
|
)
|
|
from providers import ChatResult, ProviderConfig
|
|
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"] == "bypass", "fake-env default is bypass")
|
|
expect(get_detect_operating_mode() == "bypass", "store default in tests is bypass")
|
|
ner = providers.json().get("local_ner") or {}
|
|
expect(ner.get("enabled") is False, "GLiNER is off by default")
|
|
detect_before = next(item for item in providers.json()["roles"] if item["role"] == "detect")
|
|
remote_gliner = client.put(
|
|
"/api/admin/providers/local-ner",
|
|
headers=headers,
|
|
json={"url": "https://openrouter.ai/api/v1"},
|
|
)
|
|
expect(remote_gliner.status_code == 400, "GLiNER URL must stay local")
|
|
enabled = client.put(
|
|
"/api/admin/providers/local-ner",
|
|
headers=headers,
|
|
json={"enabled": True, "url": "http://192.168.2.144:8090"},
|
|
)
|
|
status_ok(enabled, "enable local GLiNER")
|
|
expect(enabled.json()["local_ner"]["enabled"] is True, "GLiNER enabled flag persisted")
|
|
expect(enabled.json()["local_ner"]["url"] == "http://192.168.2.144:8090", "GLiNER LAN URL persisted")
|
|
detect_after = next(item for item in enabled.json()["roles"] if item["role"] == "detect")
|
|
expect(detect_after.get("url") == detect_before.get("url"), "OpenRouter detect URL is unchanged")
|
|
client.put("/api/admin/providers/local-ner", headers=headers, json={"enabled": False})
|
|
|
|
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, "bypass turn")
|
|
expect(not semantic_turn.json().get("pending_mask_review"), "bypass mode does not pause for review")
|
|
expect(any(item.get("role") == "assistant" for item in semantic_turn.json().get("messages") or []), "bypass turn gets an assistant")
|
|
|
|
switched = client.put("/api/admin/providers/detect-mode", headers=headers, json={"mode": "learning"})
|
|
status_ok(switched, "switch to learning alias")
|
|
expect(switched.json()["detect_operating_mode"] == "learning_full", "learning alias stores learning_full")
|
|
|
|
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")
|
|
hanna_conflict = hanna_again.json().get("pending_mask_review") or {}
|
|
expect(
|
|
bool(hanna_conflict.get("id")),
|
|
"teacher PERSON versus stored not_identity cue stays a visible type conflict",
|
|
)
|
|
|
|
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")
|
|
|
|
dish = "Ich aß Sushi."
|
|
dish_at = dish.index("Sushi")
|
|
food_spans = [{"text": "Sushi", "start": dish_at, "end": dish_at + 5, "label": "food", "score": 0.8}]
|
|
food_cands, _maps = merge_gliner_candidates(profile_id, [], [], dish, dish, food_spans)
|
|
expect(
|
|
any(item.get("text") == "Sushi" and item.get("suggested") == "not_identity" for item in food_cands),
|
|
"GLiNER food span suggests not_identity",
|
|
)
|
|
person_line = "Ich war mit meiner Frau Sushi unterwegs."
|
|
person_at = person_line.index("Sushi")
|
|
gliner_miss = [{"text": "Sushi", "start": person_at, "end": person_at + 5, "label": "food", "score": 0.9}]
|
|
merged, _maps = merge_gliner_candidates(profile_id, [], [], person_line, person_line, gliner_miss)
|
|
expect(
|
|
not any(item.get("suggested") == "not_identity" for item in merged),
|
|
"GLiNER food does not override Frau attachment",
|
|
)
|
|
expect(
|
|
decision_for_mention(profile_id, "Sushi", person_line, person_at, person_at + 5, person_line) == "identity",
|
|
"learned Frau cue wins over GLiNER food",
|
|
)
|
|
dish_line = "Ich aß Sushi."
|
|
dish_pos = dish_line.index("Sushi")
|
|
expect(
|
|
decision_for_mention(profile_id, "Sushi", dish_line, dish_pos, dish_pos + 5, dish_line) == "ask",
|
|
"Tag-21: identity hits plus new cue stay visible",
|
|
)
|
|
person = MentionSpan("Sushi", 20, 25, "PERSON", "identity", "cue", True, "frau")
|
|
food = MentionSpan("Sushi", 20, 25, "FOOD", "not_identity", "teacher", False, "aß")
|
|
expect(same_mention(person, food), "same offsets are the same mention")
|
|
wrapped = MentionSpan("Frau Sushi", 15, 25, "PERSON", "identity", "teacher", False, "frau")
|
|
expect(same_mention(person, wrapped), "contained Sushi matches Frau Sushi")
|
|
expect(map_entity_type("person") == "PERSON", "GLiNER person maps to PERSON")
|
|
review, counters = compare_mentions(profile_id, dish_line, [person], [food], dish_line)
|
|
expect(counters["diff_type_conflict"] >= 1, "FOOD versus PERSON is a type conflict")
|
|
expect(any(item.get("reason") == "type_conflict" for item in review), "type conflict is offered")
|
|
compact = compact_diff_counters(counters)
|
|
dumped = str(compact)
|
|
expect("Sushi" not in dumped and "Frau" not in dumped, "compact diff counters carry no plaintext")
|
|
expect(set(compact) == {
|
|
"diff_agree",
|
|
"diff_only_student",
|
|
"diff_only_teacher",
|
|
"diff_type_conflict",
|
|
"diff_new_identity",
|
|
"diff_cue_reinforced",
|
|
}, "compact counters are the agreed keys")
|
|
set_detect_operating_mode("learning_reduced")
|
|
expect(get_detect_operating_mode() == "learning_reduced", "stage 2 mode persists")
|
|
set_detect_operating_mode("learning_manual")
|
|
expect(get_detect_operating_mode() == "learning_manual", "manual mode persists")
|
|
set_detect_operating_mode("student")
|
|
pipe = prepare_detect_pipeline(profile_id, "Anna kam vorbei.", "Anna kam vorbei.")
|
|
expect(pipe.mode == "student", "student path is selectable")
|
|
expect(not pipe.need_review, "zielpfad does not open a teacher popup")
|
|
record_sense(profile_id, "Sushi", identity=True)
|
|
record_cue(profile_id, "Sushi", "frau", "identity", "meine Frau Sushi")
|
|
expect(
|
|
decision_for_mention(profile_id, "Sushi", "Ich aß Sushi.", dish_pos, dish_pos + 5) == "ask",
|
|
"Tag-21 remains ask after more identity hits",
|
|
)
|
|
|
|
fake_passage = ProviderConfig(
|
|
role="passage",
|
|
name="ollama",
|
|
mode="http",
|
|
url="http://192.168.2.144:11434/v1/chat/completions",
|
|
model="phi3:mini",
|
|
key="",
|
|
local=True,
|
|
zdr=True,
|
|
no_train=True,
|
|
)
|
|
with patch("detect_learning.passage_provider", return_value=fake_passage):
|
|
with patch("providers.complete_chat", return_value=ChatResult(content='{"decision":"not_identity"}')) as chat:
|
|
decided = try_local_passage_decision("Rohan Sushi essen", "Sushi")
|
|
expect(decided == "not_identity", "passage profile can decide without local Detect")
|
|
expect(chat.called, "passage uses complete_chat")
|
|
expect(passage_provider() is None, "no passage profile means no silent detect fallback")
|
|
|
|
back = client.put("/api/admin/providers/detect-mode", headers=headers, json={"mode": "semantic"})
|
|
expect(back.json()["detect_operating_mode"] == "bypass", "semantic alias stores bypass")
|
|
full = client.put("/api/admin/providers/detect-mode", headers=headers, json={"mode": "learning_full"})
|
|
expect(full.json()["detect_operating_mode"] == "learning_full", "learning_full is accepted")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|