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
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 " )
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 ,
json = { " body " : " Ich war mit meinem Sohn Rohan im Park. " } ,
)
status_ok ( kin , " kinship Rohan pause " )
kin_review = kin . json ( ) . get ( " pending_mask_review " ) or { }
kin_names = [ item . get ( " text " ) for item in kin_review . get ( " candidates " ) or [ ] ]
expect ( " Rohan " in kin_names , " Sohn Rohan is offered even when Detect does not report it " )
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-09 08:34:43 +02:00
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 ( )