2026-09-09 08:34:43 +02:00
""" 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
2026-09-10 20:08:59 +02:00
from detect_learning import (
2026-09-10 21:34:27 +02:00
cue_for_mention ,
2026-10-12 08:59:18 +02:00
decision_for_mention ,
2026-09-10 20:08:59 +02:00
excerpt_view ,
get_cue_decision ,
get_detect_operating_mode ,
get_sense ,
2026-10-12 08:59:18 +02:00
merge_gliner_candidates ,
passage_provider ,
record_cue ,
2026-09-10 20:08:59 +02:00
record_sense ,
2026-10-12 08:59:18 +02:00
set_detect_operating_mode ,
try_local_passage_decision ,
2026-09-10 20:08:59 +02:00
)
2026-10-12 08:59:18 +02:00
from detect_pipeline import (
MentionSpan ,
compact_diff_counters ,
compare_mentions ,
map_entity_type ,
prepare_detect_pipeline ,
same_mention ,
)
from providers import ChatResult , ProviderConfig
2026-09-09 08:34:43 +02:00
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 " )
2026-10-12 08:59:18 +02:00
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 } )
2026-09-09 08:34:43 +02:00
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. " } ,
)
2026-10-12 08:59:18 +02:00
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 " )
2026-09-09 08:34:43 +02:00
switched = client . put ( " /api/admin/providers/detect-mode " , headers = headers , json = { " mode " : " learning " } )
2026-10-12 08:59:18 +02:00
status_ok ( switched , " switch to learning alias " )
expect ( switched . json ( ) [ " detect_operating_mode " ] == " learning_full " , " learning alias stores learning_full " )
2026-09-09 08:34:43 +02:00
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 " )
2026-09-10 20:08:59 +02:00
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 " )
2026-10-12 08:59:18 +02:00
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 " ,
)
2026-09-09 08:34:43 +02:00
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 " )
2026-09-09 08:47:44 +02:00
kin_conv = open_conv ( client , headers )
kin = client . post (
f " /api/journal/conversations/ { kin_conv } /turn " ,
headers = headers ,
2026-09-10 20:08:59 +02:00
json = { " body " : " Ich war mit meinem Sohn Leon im Park. " } ,
2026-09-09 08:47:44 +02:00
)
2026-09-10 20:08:59 +02:00
status_ok ( kin , " kinship Leon pause " )
2026-09-09 08:47:44 +02:00
kin_review = kin . json ( ) . get ( " pending_mask_review " ) or { }
kin_names = [ item . get ( " text " ) for item in kin_review . get ( " candidates " ) or [ ] ]
2026-09-10 20:08:59 +02:00
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 " )
2026-09-09 08:47:44 +02:00
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 " )
2026-09-10 20:08:59 +02:00
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 " )
2026-09-10 21:34:27 +02:00
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 " )
2026-09-10 20:08:59 +02:00
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 " )
2026-09-09 08:47:44 +02:00
2026-10-12 08:59:18 +02:00
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 " )
2026-09-09 08:34:43 +02:00
back = client . put ( " /api/admin/providers/detect-mode " , headers = headers , json = { " mode " : " semantic " } )
2026-10-12 08:59:18 +02:00
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 " )
2026-09-09 08:34:43 +02:00
if __name__ == " __main__ " :
main ( )