@ -5,6 +5,7 @@ from fastapi import HTTPException
from app . db . mock_database import SessionMockLocal
from app . db . mock_models import User , Vehicle
from app . services . orchestration . technical_normalizer import is_valid_cpf
from app . services . orchestration . orchestrator_config import (
CANCEL_ORDER_REQUIRED_FIELDS ,
ORDER_REQUIRED_FIELDS ,
@ -14,7 +15,12 @@ from app.services.orchestration.orchestrator_config import (
from app . services . user . mock_customer_service import hydrate_mock_customer_from_cpf
# Esse mixin cuida dos fluxos de venda:
# criacao de pedido, selecao de veiculo e cancelamento.
class OrderFlowMixin :
def _decision_intent ( self , turn_decision : dict | None ) - > str :
return str ( ( turn_decision or { } ) . get ( " intent " ) or " " ) . strip ( ) . lower ( )
def _has_explicit_order_request ( self , message : str ) - > bool :
normalized = self . _normalize_text ( message ) . strip ( )
order_terms = {
@ -30,25 +36,25 @@ class OrderFlowMixin:
}
return any ( term in normalized for term in order_terms )
def _is_valid_cpf ( self , cpf : str ) - > bool :
digits = re . sub ( r " \ D " , " " , cpf or " " )
if len ( digits ) != 11 :
return False
if digits == digits [ 0 ] * 11 :
return False
numbers = [ int ( d ) for d in digits ]
sum_first = sum ( n * w for n , w in zip ( numbers [ : 9 ] , range ( 10 , 1 , - 1 ) ) )
first_digit = 11 - ( sum_first % 11 )
first_digit = 0 if first_digit > = 10 else first_digit
if first_digit != numbers [ 9 ] :
return False
def _has_stock_listing_request ( self , message : str , turn_decision : dict | None = None ) - > bool :
if self . _decision_intent ( turn_decision ) == " inventory_search " :
return True
normalized = self . _normalize_text ( message ) . strip ( )
stock_terms = {
" estoque " ,
" listar " ,
" liste " ,
" mostrar " ,
" mostre " ,
" ver carros " ,
" ver veiculos " ,
" opcoes " ,
" modelos " ,
}
return any ( term in normalized for term in stock_terms )
sum_second = sum ( n * w for n , w in zip ( numbers [ : 10 ] , range ( 11 , 1 , - 1 ) ) )
second_digit = 11 - ( sum_second % 11 )
second_digit = 0 if second_digit > = 10 else second_digit
return second_digit == numbers [ 10 ]
def _is_valid_cpf ( self , cpf : str ) - > bool :
return is_valid_cpf ( cpf )
def _try_prefill_order_cpf_from_memory ( self , user_id : int | None , payload : dict ) - > None :
if user_id is None or payload . get ( " cpf " ) :
@ -88,6 +94,31 @@ class OrderFlowMixin:
selected_vehicle = context . get ( " selected_vehicle " )
return dict ( selected_vehicle ) if isinstance ( selected_vehicle , dict ) else None
def _remember_stock_results ( self , user_id : int | None , stock_results : list [ dict ] | None ) - > None :
context = self . _get_user_context ( user_id )
if not context :
return
sanitized : list [ dict ] = [ ]
for item in stock_results or [ ] :
if not isinstance ( item , dict ) :
continue
try :
vehicle_id = int ( item . get ( " id " ) )
preco = float ( item . get ( " preco " ) or 0 )
except ( TypeError , ValueError ) :
continue
sanitized . append (
{
" id " : vehicle_id ,
" modelo " : str ( item . get ( " modelo " ) or " " ) . strip ( ) ,
" categoria " : str ( item . get ( " categoria " ) or " " ) . strip ( ) ,
" preco " : preco ,
}
)
context [ " last_stock_results " ] = sanitized
if sanitized :
context [ " selected_vehicle " ] = None
def _store_selected_vehicle ( self , user_id : int | None , vehicle : dict | None ) - > None :
if user_id is None :
return
@ -110,6 +141,26 @@ class OrderFlowMixin:
if selected_vehicle :
payload . update ( self . _vehicle_to_payload ( selected_vehicle ) )
def _build_stock_lookup_arguments ( self , user_id : int | None , payload : dict | None = None ) - > dict :
context = self . _get_user_context ( user_id )
generic_memory = context . get ( " generic_memory " , { } ) if isinstance ( context , dict ) else { }
source = payload if isinstance ( payload , dict ) else { }
budget = generic_memory . get ( " orcamento_max " )
if budget is None :
budget = source . get ( " valor_veiculo " )
arguments : dict = { }
if isinstance ( budget , ( int , float ) ) and float ( budget ) > 0 :
arguments [ " preco_max " ] = float ( budget )
perfil = generic_memory . get ( " perfil_veiculo " )
if isinstance ( perfil , list ) and perfil :
arguments [ " categoria " ] = str ( perfil [ 0 ] ) . strip ( ) . lower ( )
arguments [ " limite " ] = 5
arguments [ " ordenar_preco " ] = " asc "
return arguments
def _match_vehicle_from_message_index ( self , message : str , stock_results : list [ dict ] ) - > dict | None :
tokens = [ token for token in re . findall ( r " \ d+ " , str ( message or " " ) ) if token . isdigit ( ) ]
if not tokens :
@ -146,6 +197,8 @@ class OrderFlowMixin:
db . close ( )
def _try_resolve_order_vehicle ( self , message : str , user_id : int | None , payload : dict ) - > dict | None :
# Primeiro tenta um vehicle_id explicito; depois tenta casar
# a resposta do usuario com a ultima lista de estoque mostrada.
vehicle_id = payload . get ( " vehicle_id " )
if isinstance ( vehicle_id , int ) and vehicle_id > 0 :
return self . _load_vehicle_by_id ( vehicle_id )
@ -189,6 +242,32 @@ class OrderFlowMixin:
lines . append ( " Pode responder com o numero da lista ou com o modelo do veiculo. " )
return " \n " . join ( lines )
async def _try_list_stock_for_order_selection (
self ,
message : str ,
user_id : int | None ,
payload : dict ,
turn_decision : dict | None = None ,
) - > str | None :
if user_id is None or not self . _has_stock_listing_request ( message , turn_decision = turn_decision ) :
return None
arguments = self . _build_stock_lookup_arguments ( user_id = user_id , payload = payload )
if " preco_max " not in arguments and " categoria " not in arguments :
return None
try :
tool_result = await self . registry . execute (
" consultar_estoque " ,
arguments ,
user_id = user_id ,
)
except HTTPException as exc :
return self . _http_exception_detail ( exc )
self . _remember_stock_results ( user_id = user_id , stock_results = tool_result if isinstance ( tool_result , list ) else [ ] )
return self . _fallback_format_tool_result ( " consultar_estoque " , tool_result )
def _render_missing_cancel_order_fields_prompt ( self , missing_fields : list [ str ] ) - > str :
labels = {
" numero_pedido " : " o numero do pedido (ex.: PED-20260305123456-ABC123) " ,
@ -203,6 +282,7 @@ class OrderFlowMixin:
user_id : int | None ,
extracted_fields : dict | None = None ,
intents : dict | None = None ,
turn_decision : dict | None = None ,
) - > str | None :
if user_id is None :
return None
@ -211,14 +291,22 @@ class OrderFlowMixin:
draft = self . state . get_entry ( " pending_order_drafts " , user_id , expire = True )
extracted = self . _normalize_order_fields ( extracted_fields )
has_intent = normalized_intents . get ( " order_create " , False )
decision_intent = self . _decision_intent ( turn_decision )
has_intent = decision_intent == " order_create " or normalized_intents . get ( " order_create " , False )
explicit_order_request = self . _has_explicit_order_request ( message )
if (
draft
and not has_intent
and (
normalized_intents . get ( " review_schedule " , False )
decision_intent in {
" review_schedule " ,
" review_list " ,
" review_cancel " ,
" review_reschedule " ,
" order_cancel " ,
}
or normalized_intents . get ( " review_schedule " , False )
or normalized_intents . get ( " review_list " , False )
or normalized_intents . get ( " review_cancel " , False )
or normalized_intents . get ( " review_reschedule " , False )
@ -229,7 +317,7 @@ class OrderFlowMixin:
self . state . pop_entry ( " pending_order_drafts " , user_id )
return None
if draft is None and not ( has_intent and explicit_order_request ) :
if draft is None and not has_intent and not explicit_order_request :
return None
if draft is None :
@ -248,6 +336,8 @@ class OrderFlowMixin:
payload = draft [ " payload " ] ,
)
if resolved_vehicle :
# Mantem a selecao no estado para que o usuario informe
# o CPF depois sem perder o veiculo escolhido.
self . _store_selected_vehicle ( user_id = user_id , vehicle = resolved_vehicle )
draft [ " payload " ] . update ( self . _vehicle_to_payload ( resolved_vehicle ) )
@ -273,6 +363,14 @@ class OrderFlowMixin:
missing = [ field for field in ORDER_REQUIRED_FIELDS if field not in draft [ " payload " ] ]
if missing :
if " vehicle_id " in missing :
stock_response = await self . _try_list_stock_for_order_selection (
message = message ,
user_id = user_id ,
payload = draft [ " payload " ] ,
turn_decision = turn_decision ,
)
if stock_response :
return stock_response
stock_results = self . _get_last_stock_results ( user_id = user_id )
if stock_results :
return self . _render_vehicle_selection_from_stock_prompt ( stock_results )
@ -299,6 +397,7 @@ class OrderFlowMixin:
user_id : int | None ,
extracted_fields : dict | None = None ,
intents : dict | None = None ,
turn_decision : dict | None = None ,
) - > str | None :
if user_id is None :
return None
@ -308,7 +407,8 @@ class OrderFlowMixin:
active_order_draft = self . state . get_entry ( " pending_order_drafts " , user_id , expire = True )
extracted = self . _normalize_cancel_order_fields ( extracted_fields )
has_intent = normalized_intents . get ( " order_cancel " , False )
decision_intent = self . _decision_intent ( turn_decision )
has_intent = decision_intent == " order_cancel " or normalized_intents . get ( " order_cancel " , False )
if (
draft is None
@ -324,7 +424,14 @@ class OrderFlowMixin:
draft
and not has_intent
and (
normalized_intents . get ( " review_schedule " , False )
decision_intent in {
" review_schedule " ,
" review_list " ,
" review_cancel " ,
" review_reschedule " ,
" order_create " ,
}
or normalized_intents . get ( " review_schedule " , False )
or normalized_intents . get ( " review_list " , False )
or normalized_intents . get ( " review_cancel " , False )
or normalized_intents . get ( " review_reschedule " , False )
@ -349,6 +456,8 @@ class OrderFlowMixin:
and draft [ " payload " ] . get ( " numero_pedido " )
and not has_intent
) :
# Quando o pedido ja foi identificado, um texto livre curto
# e tratado como motivo do cancelamento.
free_text = ( message or " " ) . strip ( )
if free_text and len ( free_text ) > = 4 :
extracted [ " motivo " ] = free_text