2026-08-26 10:42:20 +02:00
""" Journal budget, two-stage generation, provider payload. Run from backend/: python tests/test_journal_budget.py """
from __future__ import annotations
import json
import os
import sys
import tempfile
from pathlib import Path
from unittest . mock import patch
ROOT = Path ( __file__ ) . resolve ( ) . parents [ 1 ]
sys . path . insert ( 0 , str ( ROOT ) )
2026-09-07 14:28:17 +02:00
from tests . harness import configure_test_engine
configure_test_engine ( )
2026-08-26 10:42:20 +02:00
os . environ [ " KANSHO_FAKE_PROVIDER " ] = " 1 "
os . environ [ " KANSHO_FAKE_DETECT " ] = " 1 "
from fastapi . testclient import TestClient
from main import app
from journal_reconstruct import parse_reconstruction , reconstruction_from_model , validate_reconstruction
from model_catalog import ModelWindow , reset_catalog , set_metadata_override
from prompt_budget import (
ERROR_MODEL_CONTEXT_TOO_SMALL ,
ERROR_NO_USER_SOURCES ,
ERROR_OUTPUT_LIMIT_UNSUPPORTED ,
ERROR_PROMPT_BUDGET_EXCEEDED ,
ERROR_RECONSTRUCTION_INVALID ,
JournalBudgetError ,
assert_input_fits ,
estimate_tokens ,
plan_journal_budget ,
)
from providers import ChatResult , ProviderConfig , complete_chat , is_openrouter
from retrieval import pair_user_priority_messages
from writing_profile_store import (
TASK_BRIEF_MAX_CHARS ,
compile_task_brief ,
get_profile ,
import_text ,
replace_trait_refs ,
2026-08-27 10:03:05 +02:00
set_lifecycle ,
2026-08-26 10:42:20 +02:00
upsert_trait ,
update_facet ,
)
def expect ( ok : bool , message : str ) - > None :
if not ok :
raise SystemExit ( f " FAIL: { message } " )
print ( f " OK { message } " )
def header ( token : str ) - > dict :
return { " X-Auth-Token " : token }
def _window ( context : int , completion : int = 4096 , model : str = " test/model " ) - > ModelWindow :
return ModelWindow (
model = model ,
context_length = context ,
max_completion_tokens = completion ,
source = " test " ,
provider = " test " ,
)
def _openrouter_config ( * * kwargs ) - > ProviderConfig :
data = dict (
role = " generate " ,
name = " openrouter " ,
mode = " http " ,
url = " https://openrouter.ai/api/v1/chat/completions " ,
model = " openai/gpt-4o " ,
key = " sk-test " ,
local = False ,
zdr = True ,
no_train = True ,
)
data . update ( kwargs )
return ProviderConfig ( * * data )
def test_budget_math ( ) - > None :
window = _window ( 32_768 )
budget = plan_journal_budget ( window , purpose = " journal_generate " )
expect ( budget . reserved_output_tokens == 4096 , " journal reserves 4096 completion tokens " )
expect ( budget . safety_margin == 0.15 , " documented safety margin " )
expect ( budget . available_input_tokens > 0 , " input budget remains after output reserve " )
expect (
budget . estimated_input_tokens + budget . reserved_output_tokens
< = int ( budget . effective_context_window * ( 1 - budget . safety_margin ) )
or budget . estimated_input_tokens == 0 ,
" empty estimate still has a planned ceiling " ,
)
typical = " user: " + ( " Heute Markt. " * 40 )
fitted = assert_input_fits ( budget , typical )
expect ( fitted . budget_ok is True , " typical current journal request fits 32K " )
expect ( fitted . estimated_input_tokens > = estimate_tokens ( typical ) , " estimate includes overhead " )
huge = " Wort " * 80_000
try :
assert_input_fits ( plan_journal_budget ( window , purpose = " journal_generate " ) , huge )
raise SystemExit ( " FAIL: oversized input should miss the budget " )
except JournalBudgetError as exc :
expect ( exc . code == ERROR_PROMPT_BUDGET_EXCEEDED , " oversized input is prompt_budget_exceeded " )
try :
plan_journal_budget ( _window ( 8192 ) , purpose = " journal_generate " )
raise SystemExit ( " FAIL: 8K model should be rejected " )
except JournalBudgetError as exc :
expect ( exc . code == ERROR_MODEL_CONTEXT_TOO_SMALL , " 8K model is model_context_too_small " )
try :
plan_journal_budget ( _window ( 32_768 , completion = 100 ) , purpose = " journal_generate " )
raise SystemExit ( " FAIL: tiny completion window should be rejected " )
except JournalBudgetError as exc :
expect ( exc . code == ERROR_OUTPUT_LIMIT_UNSUPPORTED , " tiny output window is output_limit_unsupported " )
capped = plan_journal_budget ( _window ( 32_768 , completion = 2048 ) , purpose = " journal_generate " )
expect ( capped . reserved_output_tokens == 2048 , " output reserve is capped by the model " )
four_char_guess = 100 # 400 chars / 4
expect ( estimate_tokens ( " abcd " * 100 ) > = four_char_guess , " conservative estimate is not looser than 4 chars/token " )
def _claim ( kind : str , evidence : str ) - > dict :
return { " kind " : kind , " evidence " : evidence }
def _chrono ( source_id : str , * , time : str | None = None , claims : list | None = None , source : str | None = " user " ) - > dict :
item = {
" source_id " : source_id ,
" time " : time ,
" claims " : claims or [ ] ,
}
if source is not None :
item [ " source " ] = source
return item
def _sourced ( source_id : str , evidence : str ) - > dict :
return { " source_id " : source_id , " evidence " : evidence }
def _expect_invalid ( payload , * , messages = None , user_bodies = None , assistant_bodies = None , reason = None ) - > None :
try :
validate_reconstruction (
payload ,
messages = messages ,
user_bodies = user_bodies ,
assistant_bodies = assistant_bodies ,
)
raise SystemExit ( f " FAIL: reconstruction should be invalid ( { reason or ' expected invalid ' } ) " )
except JournalBudgetError as exc :
expect ( exc . code == ERROR_RECONSTRUCTION_INVALID , f " { reason or ' invalid ' } is reconstruction_invalid " )
if reason :
actual = ( exc . diagnostics or { } ) . get ( " reason " )
expect ( actual == reason , f " reason is { reason } , got { actual } " )
def test_reconstruction_rules ( ) - > None :
user = [
" Heute um 6:00 Uhr Tee. Vielleicht fahren wir doch nicht. " ,
" Um 11:00 Uhr war die Bootstour, oder doch erst später. " ,
]
assistant = [ " Was davon möchtest du festhalten? Bitte erzähl den nächsten Schritt. " ]
messages = [
{ " role " : " user " , " body " : user [ 0 ] , " conversation_id " : " c1 " } ,
{ " role " : " assistant " , " body " : assistant [ 0 ] , " conversation_id " : " c1 " } ,
{ " role " : " user " , " body " : user [ 1 ] , " conversation_id " : " c1 " } ,
]
good = {
" source_order " : [ " u1 " , " u2 " ] ,
" chronology " : [
_chrono (
" u1 " ,
time = " 6:00 Uhr " ,
claims = [
_claim ( " event " , " Heute um 6:00 Uhr Tee " ) ,
_claim ( " uncertainty " , " Vielleicht fahren wir doch nicht. " ) ,
] ,
) ,
_chrono (
" u2 " ,
time = " 11:00 Uhr " ,
claims = [
_claim ( " event " , " Um 11:00 Uhr war die Bootstour " ) ,
_claim ( " uncertainty " , " oder doch erst später " ) ,
] ,
) ,
] ,
" contradictions " : [ _sourced ( " u2 " , " oder doch erst später " ) ] ,
" uncertainties " : [ _sourced ( " u1 " , " Vielleicht fahren wir doch nicht. " ) ] ,
}
validated = validate_reconstruction ( good , messages = messages )
blob = " \n " . join ( item . get ( " text " ) or " " for item in validated . get ( " sources " ) or [ ] )
expect ( validated . get ( " kind " ) == " verified_artifact " , " stage 2 payload is a verified artifact " )
expect ( " 6:00 " in blob , " canonical first source keeps its time " )
expect ( validated [ " source_order " ] == [ " u1 " , " u2 " ] , " stage 1 keeps local source order " )
expect ( " Vielleicht " in blob and " oder doch " in blob , " hedges remain as canonical wording " )
expect ( all ( item . get ( " role " ) == " user " for item in validated [ " sources " ] ) , " artifact sources are user role " )
expect ( " chronology " not in validated , " untrusted chronology is not the stage 2 payload " )
expect ( " contradictions " not in validated , " unverified contradiction labels are not verified content " )
expect ( " feeling " not in json . dumps ( validated . get ( " sources " ) ) , " source records carry no semantic kind " )
expect ( assistant [ 0 ] not in blob , " assistant wording is not in the artifact " )
dropped_middle = json . loads ( json . dumps ( good ) )
dropped_middle [ " chronology " ] = [ good [ " chronology " ] [ 0 ] ]
_expect_invalid ( dropped_middle , messages = messages , reason = " incomplete_coverage " )
three_users = [
{ " role " : " user " , " body " : " Anfang des Tages am Markt. " , " conversation_id " : " c1 " } ,
{ " role " : " user " , " body " : " Mitte: Kirschen gekauft. " , " conversation_id " : " c1 " } ,
{ " role " : " user " , " body " : " Ende am Hafen um 18:00 Uhr. " , " conversation_id " : " c1 " } ,
]
skip_middle = {
" source_order " : [ " u1 " , " u2 " , " u3 " ] ,
" chronology " : [
_chrono ( " u1 " , claims = [ _claim ( " event " , " Anfang des Tages am Markt. " ) ] ) ,
_chrono (
" u3 " ,
time = " 18:00 Uhr " ,
claims = [ _claim ( " event " , " Ende am Hafen um 18:00 Uhr " ) ] ,
) ,
] ,
" contradictions " : [ ] ,
" uncertainties " : [ ] ,
}
_expect_invalid ( skip_middle , messages = three_users , reason = " incomplete_coverage " )
swapped = json . loads ( json . dumps ( good ) )
swapped [ " source_order " ] = [ " u2 " , " u1 " ]
_expect_invalid ( swapped , messages = messages , reason = " source_order_mismatch " )
swapped_chrono = json . loads ( json . dumps ( good ) )
swapped_chrono [ " chronology " ] = [ good [ " chronology " ] [ 1 ] , good [ " chronology " ] [ 0 ] ]
_expect_invalid ( swapped_chrono , messages = messages , reason = " reorder_unjustified " )
duplicate = json . loads ( json . dumps ( good ) )
duplicate [ " chronology " ] = [ good [ " chronology " ] [ 0 ] , { * * good [ " chronology " ] [ 0 ] } ]
_expect_invalid ( duplicate , messages = messages , reason = " duplicate_source_id " )
unknown = json . loads ( json . dumps ( good ) )
unknown [ " chronology " ] [ 1 ] [ " source_id " ] = " u9 "
_expect_invalid ( unknown , messages = messages , reason = " unknown_source_id " )
as_assistant = json . loads ( json . dumps ( good ) )
as_assistant [ " chronology " ] [ 0 ] [ " source " ] = " assistant "
_expect_invalid ( as_assistant , messages = messages , reason = " invalid_source " )
bogus_source = json . loads ( json . dumps ( good ) )
bogus_source [ " chronology " ] [ 0 ] [ " source " ] = " unknown "
_expect_invalid ( bogus_source , messages = messages , reason = " invalid_source " )
missing_source = json . loads ( json . dumps ( good ) )
del missing_source [ " chronology " ] [ 0 ] [ " source " ]
_expect_invalid ( missing_source , messages = messages , reason = " missing_source " )
empty_source = json . loads ( json . dumps ( good ) )
empty_source [ " chronology " ] [ 0 ] [ " source " ] = " "
_expect_invalid ( empty_source , messages = messages , reason = " missing_source " )
assistant_id = json . loads ( json . dumps ( good ) )
assistant_id [ " chronology " ] [ 0 ] [ " source_id " ] = " a1 "
assistant_id [ " chronology " ] [ 0 ] [ " claims " ] = [ _claim ( " event " , assistant [ 0 ] ) ]
_expect_invalid ( assistant_id , messages = messages , reason = " assistant_as_fact " )
stolen = json . loads ( json . dumps ( good ) )
stolen [ " chronology " ] [ 0 ] [ " claims " ] = [ _claim ( " event " , assistant [ 0 ] ) ]
_expect_invalid ( stolen , messages = messages , reason = " assistant_as_fact " )
missing_time = json . loads ( json . dumps ( good ) )
missing_time [ " chronology " ] [ 0 ] [ " time " ] = None
missing_time [ " chronology " ] [ 0 ] [ " claims " ] = [ _claim ( " event " , " Tee " ) ]
missing_time [ " chronology " ] [ 1 ] [ " time " ] = None
missing_time [ " chronology " ] [ 1 ] [ " claims " ] = [ _claim ( " event " , " Bootstour " ) ]
kept_times = validate_reconstruction ( missing_time , messages = messages )
kept_blob = " \n " . join ( item . get ( " text " ) or " " for item in kept_times [ " sources " ] )
expect ( " 6:00 " in kept_blob and " 11:00 " in kept_blob , " omitted clock claims do not drop canonical times " )
dropped_uncertainty = json . loads ( json . dumps ( good ) )
dropped_uncertainty [ " uncertainties " ] = [ ]
dropped_uncertainty [ " chronology " ] [ 0 ] [ " claims " ] = [ _claim ( " event " , " Heute um 6:00 Uhr Tee " ) ]
dropped_uncertainty [ " chronology " ] [ 1 ] [ " claims " ] = [ _claim ( " event " , " Um 11:00 Uhr war die Bootstour " ) ]
kept_hedge = validate_reconstruction ( dropped_uncertainty , messages = messages )
hedge_blob = " \n " . join ( item . get ( " text " ) or " " for item in kept_hedge [ " sources " ] )
expect ( " Vielleicht " in hedge_blob , " canonical uncertainty wording is rehydrated without labels " )
dropped_contradiction = json . loads ( json . dumps ( good ) )
dropped_contradiction [ " contradictions " ] = [ ]
kept_contra = validate_reconstruction ( dropped_contradiction , messages = messages )
expect ( " contradictions " not in kept_contra , " omitted contradiction labels are not required as facts " )
expect ( " oder doch " in " \n " . join ( item . get ( " text " ) or " " for item in kept_contra [ " sources " ] ) , " canonical hedge remains " )
capitalized_source = json . loads ( json . dumps ( good ) )
capitalized_source [ " chronology " ] [ 0 ] [ " source " ] = " User "
_expect_invalid ( capitalized_source , messages = messages , reason = " invalid_source " )
def test_reconstruction_claim_and_time_binding ( ) - > None :
tea_messages = [
{ " role " : " user " , " body " : " Heute trank ich Tee. " , " conversation_id " : " c1 " } ,
{ " role " : " assistant " , " body " : " Bist du anschließend mit Anna nach Berlin gefahren? " , " conversation_id " : " c1 " } ,
]
invented = {
" source_order " : [ " u1 " ] ,
" chronology " : [
{
" source_id " : " u1 " ,
" source " : " user " ,
" time " : None ,
" events " : [ " Ich fuhr anschließend mit Anna nach Berlin. " ] ,
" evidence " : " Heute trank ich Tee. " ,
}
] ,
" contradictions " : [ ] ,
" uncertainties " : [ ] ,
}
_expect_invalid ( invented , messages = tea_messages , reason = " unverified_claim " )
paraphrased = {
" source_order " : [ " u1 " ] ,
" chronology " : [
_chrono (
" u1 " ,
claims = [
_claim ( " event " , " Heute trank ich Tee. " ) ,
_claim ( " event " , " Ich fuhr anschließend mit Anna nach Berlin. " ) ,
] ,
)
] ,
" contradictions " : [ ] ,
" uncertainties " : [ ] ,
}
_expect_invalid ( paraphrased , messages = tea_messages , reason = " unverified_claim " )
identical_assistant = {
" source_order " : [ " u1 " ] ,
" chronology " : [
_chrono ( " u1 " , claims = [ _claim ( " event " , " Bist du anschließend mit Anna nach Berlin gefahren? " ) ] )
] ,
" contradictions " : [ ] ,
" uncertainties " : [ ] ,
}
_expect_invalid ( identical_assistant , messages = tea_messages , reason = " assistant_as_fact " )
two_users = [
{ " role " : " user " , " body " : " Heute trank ich Tee. " , " conversation_id " : " c1 " } ,
{ " role " : " user " , " body " : " Später ging ich zum Markt. " , " conversation_id " : " c1 " } ,
]
other_source = {
" source_order " : [ " u1 " , " u2 " ] ,
" chronology " : [
_chrono ( " u1 " , claims = [ _claim ( " event " , " Später ging ich zum Markt. " ) ] ) ,
_chrono ( " u2 " , claims = [ _claim ( " event " , " Heute trank ich Tee. " ) ] ) ,
] ,
" contradictions " : [ ] ,
" uncertainties " : [ ] ,
}
_expect_invalid ( other_source , messages = two_users , reason = " wrong_source " )
each_claim_own_source = {
" source_order " : [ " u1 " ] ,
" chronology " : [
_chrono (
" u1 " ,
claims = [
_claim ( " event " , " Heute trank ich Tee. " ) ,
_claim ( " event " , " Ich fuhr anschließend mit Anna nach Berlin. " ) ,
] ,
)
] ,
" contradictions " : [ ] ,
" uncertainties " : [ ] ,
}
_expect_invalid ( each_claim_own_source , messages = tea_messages , reason = " unverified_claim " )
clock_messages = [
{ " role " : " user " , " body " : " Um 6:00 Uhr trank ich Tee. " , " conversation_id " : " c1 " } ,
{ " role " : " user " , " body " : " Um 11:00 Uhr begann die Bootstour. " , " conversation_id " : " c1 " } ,
]
swapped_times = {
" source_order " : [ " u1 " , " u2 " ] ,
" chronology " : [
_chrono (
" u2 " ,
time = " 6:00 Uhr " ,
claims = [ _claim ( " event " , " Um 11:00 Uhr begann die Bootstour. " ) ] ,
) ,
_chrono (
" u1 " ,
time = " 11:00 Uhr " ,
claims = [ _claim ( " event " , " Um 6:00 Uhr trank ich Tee. " ) ] ,
) ,
] ,
" contradictions " : [ ] ,
" uncertainties " : [ ] ,
}
_expect_invalid ( swapped_times , messages = clock_messages , reason = " time_source_mismatch " )
polluted_time = {
" source_order " : [ " u1 " , " u2 " ] ,
" chronology " : [
_chrono (
" u1 " ,
time = " 6:00 Uhr mit Anna nach Berlin " ,
claims = [ _claim ( " event " , " Um 6:00 Uhr trank ich Tee. " ) ] ,
) ,
_chrono (
" u2 " ,
time = " 11:00 Uhr " ,
claims = [ _claim ( " event " , " Um 11:00 Uhr begann die Bootstour. " ) ] ,
) ,
] ,
" contradictions " : [ ] ,
" uncertainties " : [ ] ,
}
_expect_invalid ( polluted_time , messages = clock_messages , reason = " time_source_mismatch " )
justified = {
" source_order " : [ " u1 " , " u2 " ] ,
" chronology " : [
_chrono (
" u2 " ,
time = " 06:00 " ,
claims = [ _claim ( " event " , " Um 6:00 Uhr trank ich Tee. " ) ] ,
) ,
_chrono (
" u1 " ,
time = " 11:00 Uhr " ,
claims = [ _claim ( " event " , " Um 11:00 Uhr begann die Bootstour. " ) ] ,
) ,
] ,
" contradictions " : [ ] ,
" uncertainties " : [ ] ,
}
# messages are 6:00 then 11:00 in source order; justified uses later-mentioned morning first
morning_first = [
{ " role " : " user " , " body " : " Um 11:00 Uhr begann die Bootstour. " , " conversation_id " : " c1 " } ,
{ " role " : " user " , " body " : " Um 6:00 Uhr trank ich Tee. " , " conversation_id " : " c1 " } ,
]
accepted = validate_reconstruction ( justified , messages = morning_first )
expect ( [ item [ " source_id " ] for item in accepted [ " sources " ] ] == [ " u2 " , " u1 " ] , " confirmed clocks may reorder events " )
expect ( accepted [ " source_order " ] == [ " u1 " , " u2 " ] , " source_order stays the local encounter order " )
expect ( accepted . get ( " order " ) == [ " u2 " , " u1 " ] , " artifact order follows justified chronology " )
relative_messages = [
{ " role " : " user " , " body " : " Zuerst der Markt. " , " conversation_id " : " c1 " } ,
{ " role " : " user " , " body " : " Danach der Hafen. " , " conversation_id " : " c1 " } ,
]
unjustified = {
" source_order " : [ " u1 " , " u2 " ] ,
" chronology " : [
_chrono ( " u2 " , claims = [ _claim ( " event " , " Danach der Hafen. " ) ] ) ,
_chrono ( " u1 " , claims = [ _claim ( " event " , " Zuerst der Markt. " ) ] ) ,
] ,
" contradictions " : [ ] ,
" uncertainties " : [ ] ,
}
_expect_invalid ( unjustified , messages = relative_messages , reason = " reorder_unjustified " )
equal_clocks = [
{ " role " : " user " , " body " : " Um 6:00 Uhr Tee. " , " conversation_id " : " c1 " } ,
{ " role " : " user " , " body " : " Um 6:00 Uhr noch Brot. " , " conversation_id " : " c1 " } ,
]
equal_reorder = {
" source_order " : [ " u1 " , " u2 " ] ,
" chronology " : [
_chrono ( " u2 " , time = " 6:00 Uhr " , claims = [ _claim ( " event " , " Um 6:00 Uhr noch Brot. " ) ] ) ,
_chrono ( " u1 " , time = " 6:00 Uhr " , claims = [ _claim ( " event " , " Um 6:00 Uhr Tee. " ) ] ) ,
] ,
" contradictions " : [ ] ,
" uncertainties " : [ ] ,
}
_expect_invalid ( equal_reorder , messages = equal_clocks , reason = " reorder_unjustified " )
def test_reconstruction_completeness_and_labels ( ) - > None :
meal = " Morgens kaufte ich Brot. Mittags traf ich Anna am Markt. Abends kochte ich Suppe. "
meal_messages = [
{ " role " : " user " , " body " : meal , " conversation_id " : " c1 " } ,
{ " role " : " assistant " , " body " : " War die Suppe für Gäste? " , " conversation_id " : " c1 " } ,
]
morning_only = {
" source_order " : [ " u1 " ] ,
" chronology " : [ _chrono ( " u1 " , claims = [ _claim ( " event " , " Morgens " ) ] ) ] ,
" contradictions " : [ ] ,
" uncertainties " : [ ] ,
}
full = validate_reconstruction ( morning_only , messages = meal_messages )
blob = " \n " . join ( item . get ( " text " ) or " " for item in full [ " sources " ] )
expect ( blob == meal , " bread/anna/soup source is rehydrated in full " )
expect ( " Mittags traf ich Anna am Markt " in blob , " anna sentence survives a short claim " )
expect ( " Abends kochte ich Suppe " in blob , " soup sentence survives a short claim " )
expect ( full . get ( " kind " ) == " verified_artifact " , " stage 2 payload is the local artifact " )
expect ( " Was die Suppe für Gäste " not in blob and " Gäste " not in blob , " assistant line is not rehydrated " )
rain = " Es regnete am Bahnsteig. Der Zug hatte Verspätung. Im Café bestellte ich Kaffee. "
rain_messages = [ { " role " : " user " , " body " : rain , " conversation_id " : " c1 " } ]
rain_only = {
" source_order " : [ " u1 " ] ,
" chronology " : [ _chrono ( " u1 " , claims = [ _claim ( " event " , " Es regnete " ) ] ) ] ,
" contradictions " : [ ] ,
" uncertainties " : [ ] ,
}
rain_full = validate_reconstruction ( rain_only , messages = rain_messages )
rain_blob = " \n " . join ( item . get ( " text " ) or " " for item in rain_full [ " sources " ] )
expect ( " Verspätung " in rain_blob and " Kaffee " in rain_blob , " a second long source is also fully rehydrated " )
feeling = {
" source_order " : [ " u1 " ] ,
" chronology " : [ _chrono ( " u1 " , claims = [ _claim ( " feeling " , " Morgens kaufte ich Brot. " ) ] ) ] ,
" contradictions " : [ ] ,
" uncertainties " : [ ] ,
}
labeled = validate_reconstruction ( feeling , messages = meal_messages )
dumped = json . dumps ( labeled )
expect ( " \" feeling \" " not in dumped , " kind feeling is not verified content " )
expect ( labeled [ " sources " ] [ 0 ] [ " text " ] == meal , " feeling label does not replace canonical wording " )
expect ( " annotations_unverified " not in labeled , " journal stage 2 does not receive unverified labels " )
as_contradiction = {
" source_order " : [ " u1 " ] ,
" chronology " : [ _chrono ( " u1 " , claims = [ _claim ( " event " , " Morgens kaufte ich Brot. " ) ] ) ] ,
" contradictions " : [ _sourced ( " u1 " , " Morgens kaufte ich Brot. " ) ] ,
" uncertainties " : [ ] ,
}
not_contra = validate_reconstruction ( as_contradiction , messages = meal_messages )
expect ( " contradictions " not in not_contra , " a normal event is not a verified contradiction " )
expect ( not_contra [ " sources " ] [ 0 ] [ " text " ] == meal , " canonical meal text stays intact " )
two_users = [
{ " role " : " user " , " body " : meal , " conversation_id " : " c1 " } ,
{ " role " : " user " , " body " : rain , " conversation_id " : " c1 " } ,
]
both = {
" source_order " : [ " u1 " , " u2 " ] ,
" chronology " : [
_chrono ( " u1 " , claims = [ _claim ( " event " , " Morgens " ) ] ) ,
_chrono ( " u2 " , claims = [ _claim ( " event " , " Es regnete " ) ] ) ,
] ,
" contradictions " : [ ] ,
" uncertainties " : [ ] ,
}
combined = validate_reconstruction ( both , messages = two_users )
combined_blob = " \n " . join ( item . get ( " text " ) or " " for item in combined [ " sources " ] )
expect ( meal in combined_blob and rain in combined_blob , " every selected user source is fully present " )
def test_local_fallback_covers_users ( ) - > None :
messages = [
{ " role " : " assistant " , " body " : " Wenn du magst, fang einfach an – ich höre zu. " } ,
{ " role " : " user " , " body " : " Heute Markt, Kirschen. " } ,
{ " role " : " assistant " , " body " : " Was davon möchtest du festhalten? " } ,
{ " role " : " user " , " body " : " Später der Hafen. " } ,
]
artifact , info = reconstruction_from_model ( " das ist kein JSON " , messages )
expect ( info . get ( " stage1 " ) == " local_fallback " , " broken JSON uses local coverage " )
texts = " " . join ( item . get ( " text " ) or " " for item in artifact . get ( " sources " ) or [ ] )
expect ( " Kirschen " in texts and " Hafen " in texts , " both user sources survive broken stage 1 " )
expect ( all ( item . get ( " role " ) == " user " for item in artifact . get ( " sources " ) or [ ] ) , " assistant is not a source " )
accepted , ok = reconstruction_from_model (
json . dumps (
{
" source_order " : [ " u1 " , " u2 " ] ,
" chronology " : [
{
" source_id " : " u1 " ,
" source " : " user " ,
" time " : None ,
" claims " : [ { " kind " : " event " , " evidence " : " Heute Markt, Kirschen. " } ] ,
} ,
{
" source_id " : " u2 " ,
" source " : " user " ,
" time " : None ,
" claims " : [ { " kind " : " event " , " evidence " : " Später der Hafen. " } ] ,
} ,
] ,
" contradictions " : [ ] ,
" uncertainties " : [ ] ,
" plan_changes " : [ ] ,
" corrections " : [ ] ,
}
) ,
messages ,
)
expect ( ok . get ( " stage1 " ) == " model_accepted " , " valid stage 1 still accepted " )
expect ( " Kirschen " in " " . join ( item . get ( " text " ) or " " for item in accepted . get ( " sources " ) or [ ] ) , " accepted path keeps user text " )
try :
reconstruction_from_model ( " {} " , [ { " role " : " assistant " , " body " : " Hallo " } ] )
raise SystemExit ( " FAIL: expected no_user_sources " )
except JournalBudgetError as exc :
expect ( exc . code == ERROR_NO_USER_SOURCES , " assistant-only day has no journal source " )
def test_day_messages_keep_middle ( ) - > None :
messages = [
{ " role " : " assistant " , " body " : " Frage A " } ,
{ " role " : " user " , " body " : " Anfang " } ,
{ " role " : " assistant " , " body " : " Frage B " } ,
{ " role " : " user " , " body " : " Mitte des Tages, Markt " } ,
{ " role " : " assistant " , " body " : " Frage C " } ,
{ " role " : " user " , " body " : " Ende " } ,
]
paired = pair_user_priority_messages ( messages )
bodies = [ item [ " body " ] for item in paired if item [ " role " ] == " user " ]
expect ( bodies == [ " Anfang " , " Mitte des Tages, Markt " , " Ende " ] , " middle user content stays " )
expect ( all ( item [ " role " ] in { " user " , " assistant " } for item in paired ) , " only dialogue roles " )
expect ( paired [ 0 ] [ " role " ] == " assistant " and paired [ 1 ] [ " body " ] == " Anfang " , " assistant kept only as reply context " )
def test_conversation_boundary_pairing ( ) - > None :
messages = [
{ " role " : " user " , " body " : " Tag in Gespräch A " , " conversation_id " : " A " , " seq " : 1 } ,
{ " role " : " assistant " , " body " : " Frage aus A " , " conversation_id " : " A " , " seq " : 2 } ,
{ " role " : " user " , " body " : " Tag in Gespräch B " , " conversation_id " : " B " , " seq " : 1 } ,
]
paired = pair_user_priority_messages ( messages )
bodies = [ item [ " body " ] for item in paired ]
expect ( " Tag in Gespräch A " in bodies and " Tag in Gespräch B " in bodies , " all user messages remain " )
expect ( " Frage aus A " not in bodies , " trailing assistant from A is dropped at the conversation boundary " )
expect ( bodies . index ( " Tag in Gespräch A " ) < bodies . index ( " Tag in Gespräch B " ) , " conversation order stays stable " )
opening = [
{ " role " : " assistant " , " body " : " Impuls A " , " conversation_id " : " A " , " seq " : 1 } ,
{ " role " : " user " , " body " : " Tag in Gespräch A " , " conversation_id " : " A " , " seq " : 2 } ,
{ " role " : " assistant " , " body " : " Frage aus A " , " conversation_id " : " A " , " seq " : 3 } ,
{ " role " : " assistant " , " body " : " Impuls B " , " conversation_id " : " B " , " seq " : 1 } ,
{ " role " : " user " , " body " : " Tag in Gespräch B " , " conversation_id " : " B " , " seq " : 2 } ,
]
opened = pair_user_priority_messages ( opening )
opened_bodies = [ item [ " body " ] for item in opened ]
expect ( opened_bodies == [ " Impuls A " , " Tag in Gespräch A " , " Impuls B " , " Tag in Gespräch B " ] , " opening assistant stays with its own user " )
expect ( " Frage aus A " not in opened_bodies , " trailing A reply is still dropped after an opening " )
for index , item in enumerate ( opened ) :
if item [ " conversation_id " ] == " B " and item [ " role " ] == " user " and index :
prev = opened [ index - 1 ]
expect (
not ( prev [ " role " ] == " assistant " and prev [ " conversation_id " ] == " A " ) ,
" A assistant is never glued onto B ' s user " ,
)
def test_provider_payload ( ) - > None :
captured = { }
class Response :
status_code = 200
def json ( self ) :
return {
" model " : " openai/gpt-4o " ,
" choices " : [ { " message " : { " content " : " ok " } } ] ,
" usage " : {
" prompt_tokens " : 21 ,
" completion_tokens " : 7 ,
" total_tokens " : 28 ,
" cost " : 0.0012 ,
} ,
}
def fake_post ( url , json = None , headers = None , timeout = None ) :
captured [ " url " ] = url
captured [ " json " ] = json
return Response ( )
remote = _openrouter_config ( )
with patch ( " providers.httpx.post " , fake_post ) :
result = complete_chat (
remote ,
[ { " role " : " user " , " content " : " ping " } ] ,
timeout = 5 ,
max_tokens = 4096 ,
disable_context_compression = True ,
)
expect ( isinstance ( result , ChatResult ) , " complete_chat returns usage-aware result " )
expect ( captured [ " json " ] [ " max_tokens " ] == 4096 , " journal sets max_tokens " )
expect (
captured [ " json " ] [ " plugins " ] == [ { " id " : " context-compression " , " enabled " : False } ] ,
" OpenRouter journal disables context compression " ,
)
expect ( result . usage . get ( " prompt_tokens " ) == 21 , " usage prompt_tokens kept " )
expect ( result . usage . get ( " cost " ) == 0.0012 , " usage cost kept " )
expect ( result . context_compression == " disabled " , " compression flag recorded " )
local = ProviderConfig (
role = " generate " ,
name = " ollama " ,
mode = " http " ,
url = " http://127.0.0.1:11434/v1/chat/completions " ,
model = " llama " ,
key = " " ,
local = True ,
zdr = True ,
no_train = True ,
)
captured . clear ( )
with patch ( " providers.httpx.post " , fake_post ) :
complete_chat (
local ,
[ { " role " : " user " , " content " : " ping " } ] ,
timeout = 5 ,
max_tokens = 4096 ,
disable_context_compression = True ,
)
expect ( " plugins " not in captured [ " json " ] , " local providers do not get OpenRouter plugins " )
expect ( " provider " not in captured [ " json " ] , " local providers do not get OpenRouter provider block " )
expect ( is_openrouter ( remote ) is True , " openrouter url is detected " )
expect ( is_openrouter ( local ) is False , " localhost is not openrouter " )
class Reject :
status_code = 400
text = " This endpoint ' s maximum context length is 8192 tokens "
def json ( self ) :
return { " error " : { " message " : self . text } }
with patch ( " providers.httpx.post " , lambda * args , * * kwargs : Reject ( ) ) :
try :
complete_chat ( remote , [ { " role " : " user " , " content " : " x " } ] , timeout = 5 , max_tokens = 4096 )
raise SystemExit ( " FAIL: context-length reject should raise " )
except Exception as exc :
expect ( getattr ( exc , " code " , " " ) == " provider_context_length_rejected " , " provider context reject is distinct " )
def _ends_on_word_boundary ( text : str ) - > bool :
if not text :
return True
return text [ - 1 ] . isalnum ( ) is False or text . split ( ) [ - 1 ] . isalnum ( )
def test_task_brief_and_dedupe ( client : TestClient , headers : dict , profile_id : str ) - > None :
long_core = (
" Lange Sätze mit konkreten Uhren und Orten, selten Pathos, oft ein trockener Schnitt. "
* 20
)
update_facet ( profile_id , " core " , value = long_core )
update_facet (
profile_id ,
" autobiographical_journal " ,
value = " Urlaubstagebücher bleiben chronologisch und nennen Zeiten, ohne den Core zu wiederholen. " ,
)
statements = [
( " rhythm " , " Wechselt zwischen kurzen Schnitten und längeren Sätzen. " ) ,
( " detail " , " Behält Uhren, Orte und kleine Gegenstände. " ) ,
( " chronology " , " Erzählt in der Reihenfolge des Tages, ohne Rückblenden zu erfinden. " ) ,
( " lexicon " , " Alltagswörter, wenig Schmuck, eigene Wiederholungen erlaubt. " ) ,
( " humor " , " Gelegentlich trocken, nie aufgesetzt. " ) ,
( " transitions " , " Kommt mit danach, später, irgendwann von Szene zu Szene. " ) ,
]
for slug , statement in statements :
upsert_trait (
profile_id ,
slug = slug ,
facet_key = " autobiographical_journal " ,
label = slug ,
statement = statement ,
origin = " manual " ,
force = True ,
)
replace_trait_refs (
profile_id ,
slug ,
[
{
" role " : " exemplar " ,
" excerpt " : f " Langes Beispiel für { slug } " + ( " Wortfolge " * 40 ) ,
" occurred_at " : " 2026-08-24 " ,
}
] ,
)
2026-08-27 10:03:05 +02:00
set_lifecycle ( profile_id , " confirmed " )
2026-08-26 10:42:20 +02:00
before = get_profile ( profile_id )
brief = compile_task_brief ( profile_id , " journal_generate " )
after = get_profile ( profile_id )
expect ( len ( brief ) < = TASK_BRIEF_MAX_CHARS , f " task brief stays in budget ( { len ( brief ) } ) " )
expect ( brief == brief . strip ( ) , " task brief is not mid-trim whitespace " )
expect ( _ends_on_word_boundary ( brief ) , " task brief does not cut inside a word " )
for slug , statement in statements :
expect ( slug in brief or statement . split ( " , " ) [ 0 ] in brief , f " relevant trait { slug } can be included " )
2026-08-27 10:03:05 +02:00
expect ( " Repräsentative Exemplare: " not in brief , " historical exemplars are not mixed into the compact brief " )
expect ( len ( [ line for line in brief . splitlines ( ) if line . startswith ( " - " ) ] ) > = 6 , " six traits considered " )
2026-08-26 10:42:20 +02:00
expect ( before [ " sources " ] == after [ " sources " ] , " compile_task_brief does not mutate stored sources " )
expect (
[ ( item . get ( " slug " ) , item . get ( " statement " ) ) for item in before [ " traits " ] ]
== [ ( item . get ( " slug " ) , item . get ( " statement " ) ) for item in after [ " traits " ] ] ,
" stored traits stay the source of truth " ,
)
body = " Am 24.08. war der Hafen ruhig, später der Markt. "
import_text ( profile_id , body , occurred_at = " 2026-08-24 " )
import_text ( profile_id , body , occurred_at = " 2026-08-24 " )
imported = get_profile ( profile_id )
same_day = [
item
for item in imported [ " sources " ]
if item . get ( " kind " ) == " imported_text " and ( item . get ( " occurred_at " ) or " " ) . startswith ( " 2026-08-24 " )
]
expect ( len ( same_day ) > = 2 , " duplicate originals remain stored " )
display = imported . get ( " compiled_brief " ) or " "
expect ( display . count ( " Hafen ruhig " ) == 1 , " duplicate brief sources are not shown twice " )
def test_generate_flow ( client : TestClient , headers : dict ) - > None :
import privacy_gateway
from privacy_gateway import install_test_recorder , reset_debug
expect ( not hasattr ( privacy_gateway , " debug_history " ) , " production has no global prompt history " )
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-08-25 " } ,
)
conv = client . post (
f " /api/journal/days/ { day . json ( ) [ ' day ' ] [ ' id ' ] } /conversations " ,
headers = headers ,
json = { " title " : " Tag " } ,
)
turn = client . post (
f " /api/journal/conversations/ { conv . json ( ) [ ' id ' ] } /turn " ,
headers = headers ,
json = { " body " : " Heute um 7:30 Uhr Markt, danach Kirschen. Vielleicht bleibe ich kürzer. " } ,
)
expect ( turn . status_code == 200 , f " turn { turn . status_code } " )
turn2 = client . post (
f " /api/journal/conversations/ { conv . json ( ) [ ' id ' ] } /turn " ,
headers = headers ,
json = { " body " : " Später noch der Hafen, ungefähr 18:00 Uhr. " } ,
)
expect ( turn2 . status_code == 200 , f " second turn { turn2 . status_code } " )
reset_debug ( )
recorder = install_test_recorder ( )
gen = client . post (
f " /api/journal/days/ { day . json ( ) [ ' day ' ] [ ' id ' ] } /generate " ,
headers = headers ,
json = { " conversation_ids " : [ conv . json ( ) [ " id " ] ] } ,
)
expect ( gen . status_code == 200 , f " generate { gen . status_code } " )
2026-08-27 10:03:05 +02:00
expect ( [ item . get ( " purpose " ) for item in recorder ] == [ " journal_generate " ] , " runtime generate calls only narration " )
run_log = gen . json ( ) . get ( " run_log " ) or ( gen . json ( ) . get ( " trace " ) or { } ) . get ( " log " ) or [ ]
expect ( any ( item . get ( " kind " ) == " model_call " for item in run_log ) , " run log records model calls " )
expect (
any ( item . get ( " kind " ) == " stage1_result " and item . get ( " stage " ) == " local_source_artifact " for item in run_log ) ,
" run log records local stage 1 " ,
)
expect ( sum ( 1 for item in run_log if item . get ( " kind " ) == " detect " ) < = 1 , " at most one detect call " )
expect ( sum ( 1 for item in run_log if item . get ( " kind " ) == " model_call " ) == 1 , " exactly one generate model call " )
expect (
not any ( key in ( item or { } ) for item in run_log for key in ( " intern " , " egress " , " raw " , " reply " , " local_label " ) ) ,
" run log omits prompt bodies and labels " ,
)
2026-08-26 10:42:20 +02:00
stages = { item . get ( " purpose " ) : item for item in ( gen . json ( ) . get ( " trace " ) or { } ) . get ( " stages " ) or [ ] }
2026-08-27 10:03:05 +02:00
expect ( " local_source_artifact " in stages , " stage 1 is the local source artifact " )
2026-08-26 10:42:20 +02:00
expect ( " journal_generate " in stages , " stage 2 ran through the gateway " )
2026-08-27 10:03:05 +02:00
expect ( " journal_reconstruct " not in stages , " runtime path does not call journal_reconstruct " )
reconstruct = stages [ " local_source_artifact " ] . get ( " intern " ) or " "
2026-08-26 10:42:20 +02:00
narrate = stages [ " journal_generate " ] . get ( " intern " ) or " "
2026-08-27 10:03:05 +02:00
expect ( stages [ " local_source_artifact " ] . get ( " status " ) == " local_ok " , " local stage 1 succeeded " )
expect ( stages [ " local_source_artifact " ] . get ( " provider " ) in ( None , " " ) , " local stage has no provider " )
expect ( stages [ " local_source_artifact " ] . get ( " model " ) in ( None , " " ) , " local stage has no model " )
expect ( ( stages [ " local_source_artifact " ] . get ( " budget " ) or { } ) . get ( " prompt_tokens " ) == 0 , " local stage has no tokens " )
expect ( " verified_artifact " in reconstruct and " u1 " in reconstruct , " local artifact contains source ids " )
expect ( " assistant: " not in reconstruct , " assistant lines are not in the artifact " )
expect ( " Markt " in reconstruct and " Hafen " in reconstruct , " every selected user source is in the artifact " )
2026-08-26 10:42:20 +02:00
expect ( " assistant: " not in narrate , " stage 2 does not resend assistant lines " )
expect ( " user: " not in narrate , " stage 2 does not resend the raw dialogue " )
2026-08-27 10:03:05 +02:00
expect ( " CURRENT_DAY_SOURCES " in narrate , " stage 2 labels current-day facts " )
expect ( " [u1] " in narrate , " stage 2 lists source ids " )
2026-08-26 10:42:20 +02:00
expect ( " 7:30 " in reconstruct or " 7:30 " in narrate , " times survive into generation " )
expect ( " Markt " in narrate and " Hafen " in narrate , " full selected user sources reach stage 2 " )
expect ( " Vielleicht bleibe ich kürzer " in narrate , " omitted labels do not drop canonical hedges " )
expect ( " assistant: " not in ( gen . json ( ) . get ( " body " ) or " " ) , " draft body has no assistant lines " )
2026-08-27 10:03:05 +02:00
expect ( " WRITING_PROFILE " in narrate or " Neutraler Journalstil " in narrate or " Core: " in narrate , " writing profile reaches generate " )
2026-08-29 20:28:59 +02:00
expect ( " STYLE_EXAMPLES " not in narrate , " default voice does not label a style-examples block " )
2026-08-28 08:40:28 +02:00
expect ( " Erzählmerkmale " not in ( narrate . split ( " \n CURRENT_DAY_SOURCES \n " ) [ 0 ] if " \n CURRENT_DAY_SOURCES \n " in narrate else narrate ) , " day dialogue is not a style brief " )
2026-08-26 10:42:20 +02:00
reset_debug ( )
short_recorder = install_test_recorder ( )
with patch (
" privacy_gateway.fake_reconstruction " ,
lambda rendered : json . dumps (
{
" source_order " : [ " u1 " , " u2 " ] ,
" chronology " : [
{
" source_id " : " u1 " ,
" source " : " user " ,
" time " : None ,
" claims " : [ { " kind " : " feeling " , " evidence " : " Heute " } ] ,
} ,
{
" source_id " : " u2 " ,
" source " : " user " ,
" time " : None ,
" claims " : [ { " kind " : " event " , " evidence " : " Später " } ] ,
} ,
] ,
" contradictions " : [ ] ,
" uncertainties " : [ ] ,
}
) ,
) :
short_gen = client . post (
f " /api/journal/days/ { day . json ( ) [ ' day ' ] [ ' id ' ] } /generate " ,
headers = headers ,
json = { " conversation_ids " : [ conv . json ( ) [ " id " ] ] } ,
)
expect ( short_gen . status_code == 200 , f " short-claim generate { short_gen . status_code } " )
expect (
2026-08-27 10:03:05 +02:00
[ item . get ( " purpose " ) for item in short_recorder ] == [ " journal_generate " ] ,
" invalid former stage-1 JSON is irrelevant because reconstruct is not called " ,
2026-08-26 10:42:20 +02:00
)
short_narrate = " "
for item in ( short_gen . json ( ) . get ( " trace " ) or { } ) . get ( " stages " ) or [ ] :
if item . get ( " purpose " ) == " journal_generate " :
short_narrate = item . get ( " intern " ) or " "
2026-08-27 10:03:05 +02:00
expect ( " Kirschen " in short_narrate and " Hafen " in short_narrate , " local artifact cannot drop selected sources " )
2026-08-26 10:42:20 +02:00
expect ( " \" feeling \" " not in short_narrate , " feeling labels do not reach stage 2 as facts " )
budget = ( gen . json ( ) . get ( " trace " ) or { } ) . get ( " budget " ) or stages [ " journal_generate " ] . get ( " budget " ) or { }
expect ( budget . get ( " reserved_output_tokens " ) == 4096 , " trace records reserved output " )
expect ( budget . get ( " context_compression " ) == " disabled " , " trace records compression off " )
expect ( budget . get ( " budget_ok " ) is True , " trace records budget ok " )
expect ( " rendered " not in budget and " masked " not in budget , " budget diagnostics omit prompt bodies " )
expect ( ( gen . json ( ) . get ( " trace " ) or { } ) . get ( " stages " ) , " admin trace has both stages " )
reset_debug ( )
skip_recorder = install_test_recorder ( )
with patch (
" privacy_gateway.fake_reconstruction " ,
lambda rendered : json . dumps (
{
" source_order " : [ " u1 " , " u2 " ] ,
" chronology " : [
{
" source_id " : " u1 " ,
" source " : " user " ,
" time " : " 7:30 Uhr " ,
" claims " : [
{ " kind " : " event " , " evidence " : " Heute um 7:30 Uhr Markt " } ,
{ " kind " : " uncertainty " , " evidence " : " Vielleicht bleibe ich kürzer. " } ,
] ,
}
] ,
" contradictions " : [ ] ,
" uncertainties " : [ { " source_id " : " u1 " , " evidence " : " Vielleicht bleibe ich kürzer. " } ] ,
}
) ,
) :
denied_recon = client . post (
f " /api/journal/days/ { day . json ( ) [ ' day ' ] [ ' id ' ] } /generate " ,
headers = headers ,
json = { " conversation_ids " : [ conv . json ( ) [ " id " ] ] } ,
)
2026-08-27 10:03:05 +02:00
expect ( denied_recon . status_code == 200 , f " former reconstruct JSON is unused { denied_recon . text } " )
2026-08-26 10:42:20 +02:00
fallback_body = denied_recon . json ( ) . get ( " body " ) or " "
expect ( " Markt " in fallback_body or " Hafen " in fallback_body , " local artifact still produces a draft " )
fallback_stages = { item . get ( " purpose " ) : item for item in ( denied_recon . json ( ) . get ( " trace " ) or { } ) . get ( " stages " ) or [ ] }
2026-08-27 10:03:05 +02:00
expect ( fallback_stages . get ( " local_source_artifact " , { } ) . get ( " stage1 " ) == " local_ok " , " stage 1 is local and successful " )
2026-08-26 10:42:20 +02:00
expect (
2026-08-27 10:03:05 +02:00
fallback_stages . get ( " local_source_artifact " , { } ) . get ( " coverage " ) == " all_selected_sources "
or " verified_artifact " in ( ( fallback_stages . get ( " local_source_artifact " ) or { } ) . get ( " intern " ) or " " ) ,
" local coverage remains all selected sources " ,
2026-08-26 10:42:20 +02:00
)
expect ( " journal_generate " in fallback_stages , " stage 2 still runs on the local artifact " )
narrate_fallback = fallback_stages . get ( " journal_generate " , { } ) . get ( " intern " ) or " "
expect ( " Heute um 7:30 Uhr Markt " in narrate_fallback , " first user source is rehydrated locally " )
2026-08-27 10:03:05 +02:00
expect ( " Später noch der Hafen " in narrate_fallback , " second user source is still in the local artifact " )
2026-08-26 10:42:20 +02:00
expect (
2026-08-27 10:03:05 +02:00
[ item . get ( " purpose " ) for item in skip_recorder ] == [ " journal_generate " ] ,
" no reconstruct provider call after unused model JSON " ,
2026-08-26 10:42:20 +02:00
)
reset_catalog ( )
set_metadata_override (
" fake " ,
ModelWindow (
model = " fake " ,
context_length = 8192 ,
max_completion_tokens = 4096 ,
source = " test " ,
provider = " fake " ,
) ,
)
denied = client . post (
f " /api/journal/days/ { day . json ( ) [ ' day ' ] [ ' id ' ] } /generate " ,
headers = headers ,
json = { " conversation_ids " : [ conv . json ( ) [ " id " ] ] } ,
)
expect ( denied . status_code == 422 , f " 8K generate is refused { denied . text } " )
detail = denied . json ( ) . get ( " detail " ) or { }
expect ( detail . get ( " code " ) == ERROR_MODEL_CONTEXT_TOO_SMALL , " 8K refusal uses model_context_too_small " )
expect ( detail . get ( " diagnostics " ) , " refusal carries diagnostics " )
reset_catalog ( )
2026-08-28 08:40:28 +02:00
def test_generate_attested_cleartext_is_normalized ( client : TestClient , headers : dict , profile_id : str ) - > None :
2026-08-26 10:42:20 +02:00
from identity_store import remember_mapping
from providers import ChatResult
remember_mapping ( profile_id , " Anna " , " PERSON:01 " )
space = client . post ( " /api/journal/spaces " , headers = headers , json = { " title " : " Identität " } )
day = client . post (
f " /api/journal/spaces/ { space . json ( ) [ ' id ' ] } /days " ,
headers = headers ,
json = { " calendar_date " : " 2026-08-26 " } ,
)
conv = client . post (
f " /api/journal/days/ { day . json ( ) [ ' day ' ] [ ' id ' ] } /conversations " ,
headers = headers ,
json = { " title " : " Tag " } ,
)
turn = client . post (
f " /api/journal/conversations/ { conv . json ( ) [ ' id ' ] } /turn " ,
headers = headers ,
json = { " body " : " Heute war ich mit Anna am Markt, danach Kirschen. " } ,
)
2026-08-28 08:40:28 +02:00
expect ( turn . status_code == 200 , f " identity-cleartext setup turn { turn . status_code } " )
calls = { " n " : 0 }
2026-08-26 10:42:20 +02:00
def leak ( _messages , _policy ) :
2026-08-28 08:40:28 +02:00
calls [ " n " ] + = 1
2026-08-26 10:42:20 +02:00
return ChatResult (
content = " Anna stand den ganzen Nachmittag am Markt. " ,
model = " fake " ,
2026-08-28 08:40:28 +02:00
usage = { " prompt_tokens " : 11 , " completion_tokens " : 9 , " total_tokens " : 20 , " cost " : 0.004 } ,
2026-08-26 10:42:20 +02:00
context_compression = " disabled " ,
)
with patch ( " privacy_gateway.complete_model " , leak ) :
gen = client . post (
f " /api/journal/days/ { day . json ( ) [ ' day ' ] [ ' id ' ] } /generate " ,
headers = headers ,
json = { " conversation_ids " : [ conv . json ( ) [ " id " ] ] } ,
)
2026-08-28 08:40:28 +02:00
expect ( gen . status_code == 200 , f " attested cleartext is accepted locally { gen . text } " )
2026-08-26 10:42:20 +02:00
payload = gen . json ( )
2026-08-27 10:03:05 +02:00
leak_log = payload . get ( " run_log " ) or ( payload . get ( " trace " ) or { } ) . get ( " log " ) or [ ]
2026-08-28 08:40:28 +02:00
expect ( calls [ " n " ] == 1 , " attested cleartext does not retry " )
expect ( not any ( item . get ( " kind " ) == " retry " for item in leak_log ) , " no privacy retry event " )
2026-08-26 10:42:20 +02:00
body = payload . get ( " body " ) or " "
2026-08-28 08:40:28 +02:00
expect ( " Anna " in body , " source-attested name is demasked " )
expect ( " Markt " in body , " model wording is kept after normalization " )
2026-08-26 10:42:20 +02:00
stages = { item . get ( " purpose " ) : item for item in ( payload . get ( " trace " ) or { } ) . get ( " stages " ) or [ ] }
2026-08-27 10:03:05 +02:00
reconstruct = stages . get ( " local_source_artifact " ) or { }
2026-08-26 10:42:20 +02:00
narrate = stages . get ( " journal_generate " ) or { }
2026-08-27 10:03:05 +02:00
expect ( reconstruct . get ( " status " ) == " local_ok " , " stage 1 stays local and does not call reconstruct " )
2026-08-28 08:40:28 +02:00
expect ( narrate . get ( " model_text_accepted " ) is True , " stage 2 accepts the normalized model text " )
expect ( ( payload . get ( " trace " ) or { } ) . get ( " generate_calls " ) == 1 or ( narrate . get ( " generate_calls " ) == 1 ) , " exactly one generate call " )
2026-08-27 10:03:05 +02:00
def test_generate_inactive_mapping_keeps_model_text ( client : TestClient , headers : dict , profile_id : str ) - > None :
from identity_store import remember_mapping
from providers import ChatResult
remember_mapping ( profile_id , " Clarissa " , " PERSON:99 " )
space = client . post ( " /api/journal/spaces " , headers = headers , json = { " title " : " Inaktiv " } )
day = client . post (
f " /api/journal/spaces/ { space . json ( ) [ ' id ' ] } /days " ,
headers = headers ,
json = { " calendar_date " : " 2026-08-26 " } ,
)
conv = client . post (
f " /api/journal/days/ { day . json ( ) [ ' day ' ] [ ' id ' ] } /conversations " ,
headers = headers ,
json = { " title " : " Tag " } ,
)
turn = client . post (
f " /api/journal/conversations/ { conv . json ( ) [ ' id ' ] } /turn " ,
headers = headers ,
json = { " body " : " Heute Markt, danach Kirschen am Hafen. " } ,
)
expect ( turn . status_code == 200 , f " inactive-mapping setup { turn . status_code } " )
calls = { " n " : 0 }
def keep ( _messages , _policy ) :
calls [ " n " ] + = 1
return ChatResult (
content = " Ein Markttag \n \n Heute Markt, danach Kirschen am Hafen. Clarissa blieb unerwähnt. " ,
model = " fake " ,
usage = { } ,
context_compression = " disabled " ,
)
with patch ( " privacy_gateway.complete_model " , keep ) :
gen = client . post (
f " /api/journal/days/ { day . json ( ) [ ' day ' ] [ ' id ' ] } /generate " ,
headers = headers ,
json = { " conversation_ids " : [ conv . json ( ) [ " id " ] ] } ,
)
2026-08-28 08:40:28 +02:00
expect ( gen . status_code == 409 , f " inactive mapping generate { gen . text } " )
detail = gen . json ( ) . get ( " detail " ) or { }
expect ( detail . get ( " code " ) == " journal_generation_not_accepted " , " historical-only name is not stored as a draft " )
expect ( detail . get ( " message " ) == " Generierung nicht übernommen. " , " API names the rejection " )
diag = detail . get ( " diagnostics " ) or { }
run_log = diag . get ( " log " ) or ( diag . get ( " trace " ) or { } ) . get ( " log " ) or [ ]
2026-08-27 10:03:05 +02:00
expect ( calls [ " n " ] == 1 , " inactive mapping does not trigger a retry " )
expect ( not any ( item . get ( " kind " ) == " retry " for item in run_log ) , " inactive mapping does not retry " )
2026-08-26 10:42:20 +02:00
expect (
2026-08-27 10:03:05 +02:00
any ( item . get ( " reason " ) == " unattested_identity " for item in run_log ) ,
" invented historical name is unattested journal content " ,
2026-08-26 10:42:20 +02:00
)
2026-08-28 08:40:28 +02:00
expect ( diag . get ( " model_text_accepted " ) is False , " unattested model text is not accepted " )
day_after = client . get ( f " /api/journal/days/ { day . json ( ) [ ' day ' ] [ ' id ' ] } " , headers = headers )
expect ( not ( day_after . json ( ) . get ( " current_draft " ) ) , " rejected generate does not insert a draft " )
trace = diag . get ( " trace " ) or { }
expect ( trace . get ( " abort_reason " ) == " unattested_identity " , " abort reason remains on the error trace " )
expect (
( trace . get ( " generate_calls " ) == 1 )
or ( ( trace . get ( " budget " ) or { } ) . get ( " generate_calls " ) == 1 )
or diag . get ( " generate_calls " ) == 1 ,
" error trace keeps the generate-call count " ,
)
2026-08-26 10:42:20 +02:00
def test_traces_are_request_scoped ( client : TestClient , headers : dict ) - > None :
from journal_generate import generate_draft
other = client . post (
" /api/users " ,
headers = headers ,
json = { " email " : " second@example.test " , " name " : " Second " , " password " : " test-pass " , " role " : " user " } ,
)
expect ( other . status_code == 200 , f " second profile { other . text } " )
login = client . post ( " /api/auth/login " , json = { " email " : " second@example.test " , " password " : " test-pass " } )
other_headers = header ( login . json ( ) [ " token " ] )
profile_b = login . json ( ) [ " profile_id " ]
space_a = client . post ( " /api/journal/spaces " , headers = headers , json = { " title " : " Profil A " } )
day_a = client . post (
f " /api/journal/spaces/ { space_a . json ( ) [ ' id ' ] } /days " ,
headers = headers ,
json = { " calendar_date " : " 2026-08-26 " } ,
)
conv_a = client . post (
f " /api/journal/days/ { day_a . json ( ) [ ' day ' ] [ ' id ' ] } /conversations " ,
headers = headers ,
json = { " title " : " A " } ,
)
token_a = " UNIQUA-ALPHA-KIRSCHEN-9921 "
client . post (
f " /api/journal/conversations/ { conv_a . json ( ) [ ' id ' ] } /turn " ,
headers = headers ,
json = { " body " : f " Heute { token_a } am Markt. " } ,
)
space_b = client . post ( " /api/journal/spaces " , headers = other_headers , json = { " title " : " Profil B " } )
day_b = client . post (
f " /api/journal/spaces/ { space_b . json ( ) [ ' id ' ] } /days " ,
headers = other_headers ,
json = { " calendar_date " : " 2026-08-26 " } ,
)
conv_b = client . post (
f " /api/journal/days/ { day_b . json ( ) [ ' day ' ] [ ' id ' ] } /conversations " ,
headers = other_headers ,
json = { " title " : " B " } ,
)
token_b = " UNIQUB-BETA-HAFEN-7744 "
client . post (
f " /api/journal/conversations/ { conv_b . json ( ) [ ' id ' ] } /turn " ,
headers = other_headers ,
json = { " body " : f " Heute { token_b } am Hafen. " } ,
)
draft_a = generate_draft (
client . get ( " /api/auth/me " , headers = headers ) . json ( ) [ " id " ] ,
day_a . json ( ) [ " day " ] [ " id " ] ,
[ conv_a . json ( ) [ " id " ] ] ,
)
draft_b = generate_draft ( profile_b , day_b . json ( ) [ " day " ] [ " id " ] , [ conv_b . json ( ) [ " id " ] ] )
intern_a = " " . join ( stage . get ( " intern " ) or " " for stage in ( draft_a . get ( " trace " ) or { } ) . get ( " stages " ) or [ ] )
intern_b = " " . join ( stage . get ( " intern " ) or " " for stage in ( draft_b . get ( " trace " ) or { } ) . get ( " stages " ) or [ ] )
expect ( token_a in intern_a , " profile A reconstruct contains its own text " )
expect ( token_b not in intern_a , " profile A trace is not mixed with profile B " )
expect ( token_b in intern_b , " profile B reconstruct contains its own text " )
expect ( token_a not in intern_b , " profile B trace is not mixed with profile A " )
def test_two_conversations_same_day ( client : TestClient , headers : dict ) - > None :
from retrieval import format_day_messages , retrieve
space = client . post ( " /api/journal/spaces " , headers = headers , json = { " title " : " Zwei Gespräche " } )
day = client . post (
f " /api/journal/spaces/ { space . json ( ) [ ' id ' ] } /days " ,
headers = headers ,
json = { " calendar_date " : " 2026-08-27 " } ,
)
conv_a = client . post (
f " /api/journal/days/ { day . json ( ) [ ' day ' ] [ ' id ' ] } /conversations " ,
headers = headers ,
json = { " title " : " A " } ,
)
client . post (
f " /api/journal/conversations/ { conv_a . json ( ) [ ' id ' ] } /turn " ,
headers = headers ,
json = { " body " : " Nur in Gespräch A: der Vormittag. " } ,
)
conv_b = client . post (
f " /api/journal/days/ { day . json ( ) [ ' day ' ] [ ' id ' ] } /conversations " ,
headers = headers ,
json = { " title " : " B " } ,
)
client . post (
f " /api/journal/conversations/ { conv_b . json ( ) [ ' id ' ] } /turn " ,
headers = headers ,
json = { " body " : " Nur in Gespräch B: der Nachmittag. " } ,
)
profile_id = client . get ( " /api/auth/me " , headers = headers ) . json ( ) [ " id " ]
messages = retrieve (
profile_id ,
{
" kind " : " day_messages " ,
" journal_day_id " : day . json ( ) [ " day " ] [ " id " ] ,
" conversation_ids " : [ conv_a . json ( ) [ " id " ] , conv_b . json ( ) [ " id " ] ] ,
" overflow " : " abort " ,
" max_estimated_tokens " : 20_000 ,
} ,
)
formatted = format_day_messages ( messages , with_source_ids = True )
expect ( " Nur in Gespräch A " in formatted and " Nur in Gespräch B " in formatted , " both conversations remain " )
id_a = conv_a . json ( ) [ " id " ]
id_b = conv_b . json ( ) [ " id " ]
a_idx = [ index for index , item in enumerate ( messages ) if item . get ( " conversation_id " ) == id_a ]
b_idx = [ index for index , item in enumerate ( messages ) if item . get ( " conversation_id " ) == id_b ]
user_b = [ index for index , item in enumerate ( messages ) if item . get ( " conversation_id " ) == id_b and item . get ( " role " ) == " user " ]
expect ( user_b , " conversation B user message is present " )
expect ( a_idx and b_idx and max ( a_idx ) < min ( b_idx ) , " conversation A stays entirely before B " )
a_kept = [ item for item in messages if item . get ( " conversation_id " ) == id_a ]
expect ( a_kept and a_kept [ - 1 ] [ " role " ] == " user " , " trailing assistant from A is dropped; last kept A line is the user " )
for index in user_b :
if index == 0 :
continue
prev = messages [ index - 1 ]
expect (
not ( prev . get ( " role " ) == " assistant " and prev . get ( " conversation_id " ) == id_a ) ,
" assistant from A is not glued to user text from B " ,
)
def main ( ) - > None :
test_budget_math ( )
test_reconstruction_rules ( )
test_reconstruction_claim_and_time_binding ( )
test_reconstruction_completeness_and_labels ( )
test_local_fallback_covers_users ( )
test_day_messages_keep_middle ( )
test_conversation_boundary_pairing ( )
test_provider_payload ( )
reset_catalog ( )
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 " ]
test_task_brief_and_dedupe ( client , headers , profile_id )
test_generate_flow ( client , headers )
2026-08-28 08:40:28 +02:00
test_generate_attested_cleartext_is_normalized ( client , headers , profile_id )
2026-08-27 10:03:05 +02:00
test_generate_inactive_mapping_keeps_model_text ( client , headers , profile_id )
2026-08-26 10:42:20 +02:00
test_traces_are_request_scoped ( client , headers )
test_two_conversations_same_day ( client , headers )
print ( " journal budget tests passed. " )
if __name__ == " __main__ " :
main ( )