@ -3,10 +3,16 @@ import unittest
from types import SimpleNamespace
from unittest . mock import patch
from sqlalchemy import create_engine
from sqlalchemy . orm import sessionmaker
from sqlalchemy . pool import StaticPool
os . environ . setdefault ( " DEBUG " , " false " )
from datetime import datetime , timedelta
from app . core . time_utils import utc_now
from app . db . mock_database import MockBase
from app . db . mock_models import RentalContract , RentalPayment , RentalVehicle
from app . services . orchestration . conversation_policy import ConversationPolicy
from app . services . orchestration . entity_normalizer import EntityNormalizer
@ -1306,6 +1312,109 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self . assertEqual ( history_calls [ 0 ] [ " turn_status " ] , " completed " )
self . assertEqual ( history_calls [ 0 ] [ " intent " ] , " general " )
async def test_handle_message_restores_outer_turn_trace_after_nested_call ( self ) :
state = FakeState (
contexts = {
1 : {
" active_domain " : " general " ,
" generic_memory " : { } ,
" shared_memory " : { } ,
" order_queue " : [ ] ,
" pending_order_selection " : None ,
" pending_switch " : None ,
" last_stock_results " : [ ] ,
" selected_vehicle " : None ,
}
}
)
history_calls = [ ]
service = OrquestradorService . __new__ ( OrquestradorService )
service . state = state
service . normalizer = EntityNormalizer ( )
service . policy = ConversationPolicy ( service = service )
service . history_service = SimpleNamespace ( record_turn = lambda * * kwargs : history_calls . append ( kwargs ) )
service . _empty_extraction_payload = service . normalizer . empty_extraction_payload
service . _log_turn_event = lambda * args , * * kwargs : None
service . _compose_order_aware_response = lambda response , user_id , queue_notice = None : response
service . _upsert_user_context = lambda user_id : None
service . _get_user_context = lambda user_id : state . get_user_context ( user_id )
service . _save_user_context = lambda user_id , context : state . save_user_context ( user_id , context )
async def fake_maybe_auto_advance_next_order ( base_response : str , user_id : int | None ) :
if base_response == " resposta externa " :
nested_response = await service . handle_message ( " mensagem interna " , user_id = user_id )
return f " { base_response } \n { nested_response } "
return base_response
async def fake_extract_turn_decision ( message : str , user_id : int | None ) :
return {
" intent " : " general " ,
" domain " : " general " ,
" action " : " answer_user " ,
" entities " : service . normalizer . empty_extraction_payload ( ) ,
" missing_fields " : [ ] ,
" selection_index " : None ,
" tool_name " : None ,
" tool_arguments " : { } ,
" response_to_user " : " resposta interna " if message == " mensagem interna " else " resposta externa " ,
}
async def fake_extract_message_plan ( message : str , user_id : int | None ) :
return { " orders " : [ { " domain " : " general " , " message " : message } ] }
service . _maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order
service . _extract_turn_decision_with_llm = fake_extract_turn_decision
service . _extract_message_plan_with_llm = fake_extract_message_plan
service . _prepare_message_for_single_order = lambda message , user_id , routing_plan = None : ( message , None , None )
service . _resolve_entities_for_message_plan = lambda message_plan , routed_message : service . normalizer . empty_extraction_payload ( )
async def fake_try_handle_immediate_context_reset ( * * kwargs ) :
return None
async def fake_try_resolve_pending_order_selection ( * * kwargs ) :
return None
async def fake_try_continue_queued_order ( * * kwargs ) :
return None
async def fake_try_execute_orchestration_control_tool ( * * kwargs ) :
return None
async def fake_try_execute_business_tool_from_turn_decision ( * * kwargs ) :
return None
service . _try_handle_immediate_context_reset = fake_try_handle_immediate_context_reset
service . _try_resolve_pending_order_selection = fake_try_resolve_pending_order_selection
service . _try_continue_queued_order = fake_try_continue_queued_order
service . _try_execute_orchestration_control_tool = fake_try_execute_orchestration_control_tool
service . _try_execute_business_tool_from_turn_decision = fake_try_execute_business_tool_from_turn_decision
service . _handle_context_switch = lambda * * kwargs : None
service . _update_active_domain = lambda * * kwargs : None
async def fake_extract_entities_with_llm ( message : str , user_id : int | None ) :
return service . normalizer . empty_extraction_payload ( )
async def fake_extract_missing_sales_search_context_with_llm ( * * kwargs ) :
return { }
service . _extract_entities_with_llm = fake_extract_entities_with_llm
service . _extract_missing_sales_search_context_with_llm = fake_extract_missing_sales_search_context_with_llm
service . _domain_from_intents = lambda intents : " general "
response = await service . handle_message ( " mensagem externa " , user_id = 1 )
self . assertEqual ( response , " resposta externa \n resposta interna " )
self . assertEqual ( len ( history_calls ) , 2 )
self . assertEqual (
{ call [ " user_message " ] for call in history_calls } ,
{ " mensagem externa " , " mensagem interna " } ,
)
self . assertEqual (
{ call [ " assistant_response " ] for call in history_calls } ,
{ " resposta externa \n resposta interna " , " resposta interna " } ,
)
self . assertEqual ( len ( { call [ " request_id " ] for call in history_calls } ) , 2 )
async def test_handle_message_persists_failed_turn_history ( self ) :
state = FakeState (
contexts = {
@ -2721,6 +2830,275 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
)
async def test_handle_message_short_circuits_for_current_rental_info_question ( self ) :
state = FakeState (
contexts = {
1 : {
" active_domain " : " general " ,
" generic_memory " : { } ,
" shared_memory " : { } ,
" order_queue " : [ ] ,
" pending_order_selection " : None ,
" pending_switch " : None ,
" last_stock_results " : [ ] ,
" selected_vehicle " : None ,
" last_rental_results " : [ ] ,
" selected_rental_vehicle " : None ,
" last_rental_contract " : {
" contrato_numero " : " LOC-20260323-CAEECA1C " ,
" placa " : " RAA1A02 " ,
" modelo_veiculo " : " Fiat Pulse " ,
" data_inicio " : " 2026-03-19T10:00:00 " ,
" data_fim_prevista " : " 2026-03-21T10:00:00 " ,
" valor_diaria " : 189.9 ,
" valor_previsto " : 379.8 ,
" status " : " ativa " ,
" status_pagamento " : " registrado " ,
" data_pagamento " : " 2026-03-23T15:47:00 " ,
" valor_pagamento " : 379.8 ,
} ,
}
}
)
service = OrquestradorService . __new__ ( OrquestradorService )
service . state = state
service . normalizer = EntityNormalizer ( )
service . policy = ConversationPolicy ( service = service )
service . _empty_extraction_payload = service . normalizer . empty_extraction_payload
service . _log_turn_event = lambda * args , * * kwargs : None
service . _compose_order_aware_response = lambda response , user_id , queue_notice = None : response
service . _get_user_context = lambda user_id : state . get_user_context ( user_id )
service . _save_user_context = lambda user_id , context : state . save_user_context ( user_id , context )
async def fake_maybe_auto_advance_next_order ( base_response : str , user_id : int | None ) :
return base_response
service . _maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order
service . _upsert_user_context = lambda user_id : None
async def fake_try_handle_pending_stock_selection_follow_up ( * * kwargs ) :
return None
async def fake_try_handle_active_sales_follow_up ( * * kwargs ) :
return None
async def fake_try_handle_pending_rental_selection_follow_up ( * * kwargs ) :
return None
async def fake_try_handle_active_rental_follow_up ( * * kwargs ) :
return None
async def fake_try_handle_active_review_follow_up ( * * kwargs ) :
return None
service . _try_handle_pending_stock_selection_follow_up = fake_try_handle_pending_stock_selection_follow_up
service . _try_handle_active_sales_follow_up = fake_try_handle_active_sales_follow_up
service . _try_handle_pending_rental_selection_follow_up = fake_try_handle_pending_rental_selection_follow_up
service . _try_handle_active_rental_follow_up = fake_try_handle_active_rental_follow_up
service . _try_handle_active_review_follow_up = fake_try_handle_active_review_follow_up
async def fake_extract_turn_decision ( message : str , user_id : int | None ) :
raise AssertionError ( " nao deveria consultar o LLM para consulta informativa do aluguel atual " )
service . _extract_turn_decision_with_llm = fake_extract_turn_decision
response = await service . handle_message (
" qual a data de devolucao do meu aluguel? " ,
user_id = 1 ,
)
self . assertIn ( " A devolucao prevista do seu aluguel e 21/03/2026 10:00. " , response )
self . assertIn ( " Contrato: LOC-20260323-CAEECA1C " , response )
self . assertIn ( " Veiculo: Fiat Pulse " , response )
async def test_handle_message_rehydrates_current_rental_info_from_db_after_restart ( self ) :
engine = create_engine (
" sqlite:// " ,
connect_args = { " check_same_thread " : False } ,
poolclass = StaticPool ,
)
SessionLocal = sessionmaker ( autocommit = False , autoflush = False , bind = engine )
MockBase . metadata . create_all ( bind = engine )
self . addCleanup ( engine . dispose )
db = SessionLocal ( )
try :
vehicle = RentalVehicle (
placa = " RAA1A02 " ,
modelo = " Fiat Pulse " ,
categoria = " suv " ,
ano = 2024 ,
valor_diaria = 189.9 ,
status = " disponivel " ,
)
db . add ( vehicle )
db . commit ( )
db . refresh ( vehicle )
contract = RentalContract (
contrato_numero = " LOC-20260323-CAEECA1C " ,
user_id = 1 ,
rental_vehicle_id = vehicle . id ,
placa = vehicle . placa ,
modelo_veiculo = vehicle . modelo ,
categoria = vehicle . categoria ,
data_inicio = datetime ( 2026 , 3 , 19 , 10 , 0 ) ,
data_fim_prevista = datetime ( 2026 , 3 , 21 , 10 , 0 ) ,
data_devolucao = None ,
valor_diaria = 189.9 ,
valor_previsto = 379.8 ,
valor_final = None ,
status = " ativa " ,
)
db . add ( contract )
db . commit ( )
db . refresh ( contract )
payment = RentalPayment (
protocolo = " ALP-20260323-0B41DD0D " ,
user_id = 1 ,
rental_contract_id = contract . id ,
contrato_numero = contract . contrato_numero ,
placa = contract . placa ,
valor = 379.8 ,
data_pagamento = datetime ( 2026 , 3 , 23 , 15 , 47 ) ,
favorecido = " Locadora XPTO " ,
identificador_comprovante = " NSU123456 " ,
observacoes = " pagamento da locacao " ,
)
db . add ( payment )
db . commit ( )
finally :
db . close ( )
state = FakeState (
contexts = {
1 : {
" active_domain " : " general " ,
" generic_memory " : { } ,
" shared_memory " : { } ,
" order_queue " : [ ] ,
" pending_order_selection " : None ,
" pending_switch " : None ,
" last_stock_results " : [ ] ,
" selected_vehicle " : None ,
" last_rental_results " : [ ] ,
" selected_rental_vehicle " : None ,
}
}
)
service = OrquestradorService . __new__ ( OrquestradorService )
service . state = state
service . normalizer = EntityNormalizer ( )
service . policy = ConversationPolicy ( service = service )
service . _empty_extraction_payload = service . normalizer . empty_extraction_payload
service . _log_turn_event = lambda * args , * * kwargs : None
service . _compose_order_aware_response = lambda response , user_id , queue_notice = None : response
service . _get_user_context = lambda user_id : state . get_user_context ( user_id )
service . _save_user_context = lambda user_id , context : state . save_user_context ( user_id , context )
async def fake_maybe_auto_advance_next_order ( base_response : str , user_id : int | None ) :
return base_response
service . _maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order
service . _upsert_user_context = lambda user_id : None
async def fake_try_handle_pending_stock_selection_follow_up ( * * kwargs ) :
return None
async def fake_try_handle_active_sales_follow_up ( * * kwargs ) :
return None
async def fake_try_handle_pending_rental_selection_follow_up ( * * kwargs ) :
return None
async def fake_try_handle_active_rental_follow_up ( * * kwargs ) :
return None
async def fake_try_handle_active_review_follow_up ( * * kwargs ) :
return None
service . _try_handle_pending_stock_selection_follow_up = fake_try_handle_pending_stock_selection_follow_up
service . _try_handle_active_sales_follow_up = fake_try_handle_active_sales_follow_up
service . _try_handle_pending_rental_selection_follow_up = fake_try_handle_pending_rental_selection_follow_up
service . _try_handle_active_rental_follow_up = fake_try_handle_active_rental_follow_up
service . _try_handle_active_review_follow_up = fake_try_handle_active_review_follow_up
async def fake_extract_turn_decision ( message : str , user_id : int | None ) :
raise AssertionError ( " nao deveria consultar o LLM para consulta informativa do aluguel apos restart " )
service . _extract_turn_decision_with_llm = fake_extract_turn_decision
with patch ( " app.services.flows.rental_flow_support.SessionMockLocal " , SessionLocal ) :
response = await service . handle_message (
" qual a data de devolucao do meu aluguel? " ,
user_id = 1 ,
)
self . assertIn ( " A devolucao prevista do seu aluguel e 21/03/2026 10:00. " , response )
self . assertIn ( " Contrato: LOC-20260323-CAEECA1C " , response )
self . assertIn ( " Veiculo: Fiat Pulse " , response )
snapshot = state . get_user_context ( 1 ) [ " last_rental_contract " ]
self . assertEqual ( snapshot [ " contrato_numero " ] , " LOC-20260323-CAEECA1C " )
self . assertEqual ( snapshot [ " status_pagamento " ] , " registrado " )
self . assertEqual ( snapshot [ " data_fim_prevista " ] , " 2026-03-21T10:00:00 " )
def test_store_last_rental_contract_preserves_contract_snapshot_after_payment_update ( self ) :
state = FakeState (
contexts = {
1 : {
" active_domain " : " general " ,
" generic_memory " : { } ,
" shared_memory " : { } ,
" order_queue " : [ ] ,
" pending_order_selection " : None ,
" pending_switch " : None ,
" last_stock_results " : [ ] ,
" selected_vehicle " : None ,
" last_rental_results " : [ ] ,
" selected_rental_vehicle " : None ,
}
}
)
service = OrquestradorService . __new__ ( OrquestradorService )
service . state = state
service . normalizer = EntityNormalizer ( )
service . _get_user_context = lambda user_id : state . get_user_context ( user_id )
service . _save_user_context = lambda user_id , context : state . save_user_context ( user_id , context )
service . _store_last_rental_contract (
user_id = 1 ,
payload = {
" contrato_numero " : " LOC-20260323-CAEECA1C " ,
" placa " : " RAA1A02 " ,
" modelo_veiculo " : " Fiat Pulse " ,
" data_inicio " : " 2026-03-19T10:00:00 " ,
" data_fim_prevista " : " 2026-03-21T10:00:00 " ,
" valor_diaria " : 189.9 ,
" valor_previsto " : 379.8 ,
" status " : " ativa " ,
} ,
)
service . _store_last_rental_contract (
user_id = 1 ,
payload = {
" contrato_numero " : " LOC-20260323-CAEECA1C " ,
" placa " : " RAA1A02 " ,
" valor " : 379.8 ,
" data_pagamento " : " 2026-03-23T15:47:00 " ,
" favorecido " : " Locadora XPTO " ,
" status " : " registrado " ,
} ,
)
snapshot = state . get_user_context ( 1 ) [ " last_rental_contract " ]
self . assertEqual ( snapshot [ " modelo_veiculo " ] , " Fiat Pulse " )
self . assertEqual ( snapshot [ " data_fim_prevista " ] , " 2026-03-21T10:00:00 " )
self . assertEqual ( snapshot [ " status " ] , " ativa " )
self . assertEqual ( snapshot [ " status_pagamento " ] , " registrado " )
self . assertEqual ( snapshot [ " data_pagamento " ] , " 2026-03-23T15:47:00 " )
self . assertEqual ( snapshot [ " valor_pagamento " ] , 379.8 )
def test_has_rental_return_management_request_ignores_return_question_even_with_last_contract ( self ) :
state = FakeState (
contexts = {
@ -3715,6 +4093,73 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self . assertIn ( " Vou comecar por: Venda: fazer pedido " , response )
async def test_pending_order_selection_promotes_new_operational_request_before_previous_options ( self ) :
state = FakeState (
contexts = {
9 : {
" pending_order_selection " : {
" orders " : [
{ " domain " : " sales " , " message " : " compra " , " seed_message " : " quero comprar um veiculo " , " memory_seed " : { } } ,
{ " domain " : " review " , " message " : " revisao " , " seed_message " : " quero agendar revisao " , " memory_seed " : { } } ,
{ " domain " : " rental " , " message " : " aluguel " , " seed_message " : " quero alugar um carro " , " memory_seed " : { } } ,
] ,
" expires_at " : utc_now ( ) + timedelta ( minutes = 15 ) ,
} ,
" order_queue " : [ ] ,
" active_domain " : " general " ,
" generic_memory " : { } ,
}
}
)
service = FakePolicyService ( state )
policy = ConversationPolicy ( service = service )
response = await policy . try_resolve_pending_order_selection (
message = " quais pedidos eu tenho? " ,
user_id = 9 ,
turn_decision = { " domain " : " sales " , " intent " : " order_list " , " action " : " call_tool " , " tool_name " : " listar_pedidos " } ,
)
self . assertIsNone ( response )
context = state . get_user_context ( 9 )
self . assertIsNone ( context [ " pending_order_selection " ] )
self . assertEqual ( [ item [ " domain " ] for item in context [ " order_queue " ] ] , [ " sales " , " review " , " rental " ] )
self . assertEqual ( context [ " order_queue " ] [ 0 ] [ " message " ] , " quero comprar um veiculo " )
async def test_pending_order_selection_skips_duplicate_base_task_when_new_request_is_more_specific ( self ) :
state = FakeState (
contexts = {
9 : {
" pending_order_selection " : {
" orders " : [
{ " domain " : " sales " , " message " : " compra " , " seed_message " : " quero comprar um veiculo " , " memory_seed " : { } } ,
{ " domain " : " review " , " message " : " revisao " , " seed_message " : " quero agendar revisao " , " memory_seed " : { } } ,
{ " domain " : " rental " , " message " : " aluguel " , " seed_message " : " quero alugar um carro " , " memory_seed " : { } } ,
] ,
" expires_at " : utc_now ( ) + timedelta ( minutes = 15 ) ,
} ,
" order_queue " : [ ] ,
" active_domain " : " general " ,
" generic_memory " : { } ,
}
}
)
service = FakePolicyService ( state )
policy = ConversationPolicy ( service = service )
response = await policy . try_resolve_pending_order_selection (
message = " quero comprar um suv ate 95 mil " ,
user_id = 9 ,
turn_decision = { " domain " : " sales " , " intent " : " order_create " , " action " : " collect_order_create " } ,
)
self . assertIn ( " Perfeito. Vou comecar por: Venda: compra " , response )
self . assertIn ( " handled:quero comprar um suv ate 95 mil " , response )
context = state . get_user_context ( 9 )
self . assertIsNone ( context [ " pending_order_selection " ] )
self . assertEqual ( context [ " active_domain " ] , " sales " )
self . assertEqual ( [ item [ " domain " ] for item in context [ " order_queue " ] ] , [ " review " , " rental " ] )
async def test_try_continue_queue_prefers_turn_decision_action ( self ) :
state = FakeState (
contexts = {
@ -3812,6 +4257,111 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self . assertIsNone ( early_response )
self . assertEqual ( service . _get_user_context ( 9 ) . get ( " order_queue " ) , [ ] )
def test_prepare_message_for_single_order_requests_clarification_for_three_actionable_domains ( self ) :
state = FakeState (
contexts = {
9 : {
" active_domain " : " general " ,
" generic_memory " : { } ,
" order_queue " : [ ] ,
" pending_order_selection " : None ,
" pending_switch " : None ,
}
} ,
)
service = FakePolicyService ( state )
policy = ConversationPolicy ( service = service )
routed_message , queue_notice , early_response = policy . prepare_message_for_single_order (
message = " oi, pode me ajudar com compra, revisao e aluguel? " ,
user_id = 9 ,
routing_plan = {
" orders " : [
{ " domain " : " sales " , " message " : " compra " } ,
{ " domain " : " review " , " message " : " revisao " } ,
]
} ,
)
self . assertEqual ( routed_message , " oi, pode me ajudar com compra, revisao e aluguel? " )
self . assertIsNone ( queue_notice )
self . assertIn ( " Identifiquei 3 acoes " , early_response )
self . assertIn ( " 3. Locacao: aluguel " , early_response )
pending = state . get_user_context ( 9 ) [ " pending_order_selection " ]
self . assertEqual ( len ( pending [ " orders " ] ) , 3 )
def test_prepare_message_for_single_order_counts_only_orders_effectively_queued ( self ) :
state = FakeState (
entries = {
" pending_review_drafts " : {
9 : {
" payload " : { " placa " : " ABC1234 " } ,
" expires_at " : utc_now ( ) + timedelta ( minutes = 15 ) ,
}
}
} ,
contexts = {
9 : {
" active_domain " : " review " ,
" generic_memory " : { } ,
" order_queue " : [ ] ,
" pending_order_selection " : None ,
" pending_switch " : None ,
}
} ,
)
service = FakePolicyService ( state )
policy = ConversationPolicy ( service = service )
routed_message , queue_notice , early_response = policy . prepare_message_for_single_order (
message = " quero continuar a revisao e tambem ver aluguel " ,
user_id = 9 ,
routing_plan = {
" orders " : [
{ " domain " : " review " , " message " : " quero continuar a revisao " } ,
{ " domain " : " general " , " message " : " oi " } ,
{ " domain " : " rental " , " message " : " quero ver aluguel " } ,
]
} ,
)
self . assertEqual ( routed_message , " quero continuar a revisao e tambem ver aluguel " )
self . assertIn ( " Anotei mais 1 pedido " , early_response )
self . assertEqual ( len ( state . get_user_context ( 9 ) [ " order_queue " ] ) , 1 )
self . assertEqual ( state . get_user_context ( 9 ) [ " order_queue " ] [ 0 ] [ " domain " ] , " rental " )
self . assertEqual ( state . get_user_context ( 9 ) [ " order_queue " ] [ 0 ] [ " message " ] , " quero ver aluguel " )
async def test_pending_order_selection_uses_canonical_seed_message_for_selected_domain ( self ) :
state = FakeState (
contexts = {
9 : {
" active_domain " : " general " ,
" generic_memory " : { } ,
" order_queue " : [ ] ,
" pending_order_selection " : None ,
" pending_switch " : None ,
}
}
)
service = FakePolicyService ( state )
policy = ConversationPolicy ( service = service )
policy . store_pending_order_selection (
user_id = 9 ,
orders = [
{ " domain " : " sales " , " message " : " compra " , " entities " : service . normalizer . empty_extraction_payload ( ) } ,
{ " domain " : " review " , " message " : " revisao " , " entities " : service . normalizer . empty_extraction_payload ( ) } ,
{ " domain " : " rental " , " message " : " aluguel " , " entities " : service . normalizer . empty_extraction_payload ( ) } ,
] ,
)
response = await policy . try_resolve_pending_order_selection ( message = " 1 " , user_id = 9 )
self . assertIn ( " Perfeito. Vou comecar por: Venda: compra " , response )
self . assertIn ( " handled:quero comprar um veiculo " , response )
context = state . get_user_context ( 9 )
self . assertEqual ( context [ " active_domain " ] , " sales " )
self . assertEqual ( [ item [ " domain " ] for item in context [ " order_queue " ] ] , [ " review " , " rental " ] )
async def test_tool_continuar_proximo_pedido_reports_empty_queue ( self ) :
state = FakeState (