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-09-10 20:08:59 +02:00
excerpt_view ,
get_cue_decision ,
get_detect_operating_mode ,
get_sense ,
record_sense ,
)
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 " )
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 " )
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 " )
expect ( not hanna_again . json ( ) . get ( " pending_mask_review " ) , " known non-identity skips the popup " )
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-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 ( )