@ -13,6 +13,7 @@ from app.services.orchestration.orchestrator_config import (
class RentalFlowMixin :
# Sanitiza resultados da frota antes de guardar no contexto.
def _sanitize_rental_results ( self , rental_results : list [ dict ] | None ) - > list [ dict ] :
sanitized : list [ dict ] = [ ]
for item in rental_results or [ ] :
@ -40,6 +41,7 @@ class RentalFlowMixin:
)
return sanitized
# Marca locacao como dominio ativo na conversa do usuario.
def _mark_rental_flow_active ( self , user_id : int | None , * , active_task : str | None = None ) - > None :
if user_id is None :
return
@ -51,6 +53,7 @@ class RentalFlowMixin:
context [ " active_task " ] = active_task
self . _save_user_context ( user_id = user_id , context = context )
# Recupera a ultima lista de veiculos disponiveis para locacao.
def _get_last_rental_results ( self , user_id : int | None ) - > list [ dict ] :
pending_selection = self . state . get_entry ( " pending_rental_selections " , user_id , expire = True )
if isinstance ( pending_selection , dict ) :
@ -65,6 +68,7 @@ class RentalFlowMixin:
rental_results = context . get ( " last_rental_results " ) or [ ]
return self . _sanitize_rental_results ( rental_results if isinstance ( rental_results , list ) else [ ] )
# Guarda a lista atual para permitir selecao do veiculo em mensagens seguintes.
def _store_pending_rental_selection ( self , user_id : int | None , rental_results : list [ dict ] | None ) - > None :
if user_id is None :
return
@ -81,6 +85,7 @@ class RentalFlowMixin:
} ,
)
# Le o veiculo de locacao escolhido que ficou salvo no contexto.
def _get_selected_rental_vehicle ( self , user_id : int | None ) - > dict | None :
context = self . _get_user_context ( user_id )
if not isinstance ( context , dict ) :
@ -88,6 +93,7 @@ class RentalFlowMixin:
selected_vehicle = context . get ( " selected_rental_vehicle " )
return dict ( selected_vehicle ) if isinstance ( selected_vehicle , dict ) else None
# Filtra o payload do contrato para manter so dados uteis no contexto.
def _sanitize_rental_contract_snapshot ( self , payload ) - > dict | None :
if not isinstance ( payload , dict ) :
return None
@ -123,6 +129,7 @@ class RentalFlowMixin:
return snapshot
# Recupera o ultimo contrato de locacao lembrado para o usuario.
def _get_last_rental_contract ( self , user_id : int | None ) - > dict | None :
context = self . _get_user_context ( user_id )
if not isinstance ( context , dict ) :
@ -130,6 +137,7 @@ class RentalFlowMixin:
contract = context . get ( " last_rental_contract " )
return dict ( contract ) if isinstance ( contract , dict ) else None
# Atualiza o ultimo contrato de locacao salvo no contexto.
def _store_last_rental_contract ( self , user_id : int | None , payload ) - > None :
if user_id is None :
return
@ -144,6 +152,7 @@ class RentalFlowMixin:
self . _save_user_context ( user_id = user_id , context = context )
# Persiste a ultima consulta de frota para reuso no fluxo incremental.
def _remember_rental_results ( self , user_id : int | None , rental_results : list [ dict ] | None ) - > None :
context = self . _get_user_context ( user_id )
if not isinstance ( context , dict ) :
@ -156,6 +165,7 @@ class RentalFlowMixin:
context [ " active_domain " ] = " rental "
self . _save_user_context ( user_id = user_id , context = context )
# Salva o veiculo escolhido e encerra a etapa de selecao pendente.
def _store_selected_rental_vehicle ( self , user_id : int | None , vehicle : dict | None ) - > None :
if user_id is None :
return
@ -167,6 +177,7 @@ class RentalFlowMixin:
self . state . pop_entry ( " pending_rental_selections " , user_id )
self . _save_user_context ( user_id = user_id , context = context )
# Converte um veiculo selecionado no payload esperado pela abertura da locacao.
def _rental_vehicle_to_payload ( self , vehicle : dict ) - > dict :
return {
" rental_vehicle_id " : int ( vehicle [ " id " ] ) ,
@ -176,6 +187,7 @@ class RentalFlowMixin:
" valor_diaria " : round ( float ( vehicle . get ( " valor_diaria " ) or 0 ) , 2 ) ,
}
# Extrai a categoria de locacao mencionada livremente pelo usuario.
def _extract_rental_category_from_text ( self , text : str ) - > str | None :
normalized = self . _normalize_text ( text ) . strip ( )
aliases = {
@ -190,6 +202,123 @@ class RentalFlowMixin:
return category
return None
# Extrai um modelo ou marca/modelo quando o pedido for mais especifico.
def _extract_rental_model_from_text ( self , text : str ) - > str | None :
normalized = self . _normalize_text ( text ) . strip ( )
if not normalized :
return None
normalized = re . sub ( r " \ b \ d { 1,2}[/-] \ d { 1,2}[/-] \ d {4} (?: \ s+ \ d { 1,2}: \ d {2} (?:: \ d {2} )?)? \ b " , " " , normalized )
normalized = re . sub ( r " \ b \ d {4} [/-] \ d { 1,2}[/-] \ d { 1,2}(?: \ s+ \ d { 1,2}: \ d {2} (?:: \ d {2} )?)? \ b " , " " , normalized )
normalized = re . sub ( r " \ b[a-z] {3} \ d[a-z0-9] \ d {2} \ b " , " " , normalized )
normalized = re . sub ( r " \ br \ $ \ s* \ d+[ \ d \ .,]* \ b " , " " , normalized )
category = self . _extract_rental_category_from_text ( normalized )
if category :
normalized = re . sub ( rf " (?<![a-z0-9]) { re . escape ( category ) } (?![a-z0-9]) " , " " , normalized )
if category == " pickup " :
normalized = re . sub ( r " (?<![a-z0-9])picape(?![a-z0-9]) " , " " , normalized )
candidate = None
cue_patterns = (
r " (?:quero|gostaria|preciso|procuro|procurando|busco|buscando) \ s+(?:alugar|locar)? \ s*(?:um|uma|o|a)? \ s*(?P<candidate>.+) " ,
r " (?:tem|ha|existe|existem|mostre|mostrar|liste|listar|quais) \ s+(?:um|uma|o|a)? \ s*(?P<candidate>.+) " ,
r " (?P<candidate>.+?) \ s+(?:para \ s+aluguel|para \ s+locacao) \ b " ,
)
for pattern in cue_patterns :
match = re . search ( pattern , normalized )
if match :
candidate = str ( match . group ( " candidate " ) or " " ) . strip ( )
if candidate :
break
if not candidate :
return None
boundary_tokens = {
" para " ,
" pra " ,
" com " ,
" sem " ,
" que " ,
" por " ,
" de " ,
" do " ,
" da " ,
" dos " ,
" das " ,
" no " ,
" na " ,
" nos " ,
" nas " ,
" automatico " ,
" automatica " ,
" automaticos " ,
" automaticas " ,
" manual " ,
" manuais " ,
" barato " ,
" barata " ,
" economico " ,
" economica " ,
}
generic_tokens = {
" aluguel " ,
" alugar " ,
" locacao " ,
" locar " ,
" carro " ,
" carros " ,
" veiculo " ,
" veiculos " ,
" modelo " ,
" categoria " ,
" tipo " ,
" disponiveis " ,
" disponivel " ,
" frota " ,
" opcoes " ,
" opcao " ,
" esta " ,
" estao " ,
" estava " ,
" estavam " ,
" existe " ,
" existem " ,
" ha " ,
" tem " ,
" um " ,
" uma " ,
" o " ,
" a " ,
" os " ,
" as " ,
" suv " ,
" sedan " ,
" hatch " ,
" pickup " ,
" picape " ,
}
tokens : list [ str ] = [ ]
for token in re . findall ( r " [a-z0-9]+ " , candidate ) :
if token in boundary_tokens :
break
if token in generic_tokens :
continue
if re . fullmatch ( r " (?:19|20) \ d {2} " , token ) :
continue
if len ( token ) < 2 :
continue
tokens . append ( token )
if len ( tokens ) > = 3 :
break
if not tokens :
return None
return " " . join ( tokens ) . title ( ) . strip ( ) or None
# Coleta datas de locacao em texto livre mantendo a ordem encontrada.
def _extract_rental_datetimes_from_text ( self , text : str ) - > list [ str ] :
normalized = technical_normalizer . normalize_datetime_connector ( text )
patterns = (
@ -204,6 +333,7 @@ class RentalFlowMixin:
results . append ( candidate )
return results
# Normaliza datas de locacao para um formato unico aceito pelo fluxo.
def _normalize_rental_datetime_text ( self , value ) - > str | None :
text = technical_normalizer . normalize_datetime_connector ( str ( value or " " ) . strip ( ) )
if not text :
@ -228,6 +358,7 @@ class RentalFlowMixin:
return parsed . strftime ( " %d / % m/ % Y % H: % M " )
return parsed . strftime ( " %d / % m/ % Y " )
# Normaliza campos estruturados de aluguel antes de montar o draft.
def _normalize_rental_fields ( self , data ) - > dict :
if not isinstance ( data , dict ) :
return { }
@ -261,6 +392,10 @@ class RentalFlowMixin:
if categoria :
payload [ " categoria " ] = categoria
model_hint = str ( data . get ( " modelo " ) or data . get ( " modelo_veiculo " ) or " " ) . strip ( " ,.; " )
if model_hint and not self . _extract_rental_category_from_text ( model_hint ) :
payload [ " modelo " ] = model_hint . title ( )
for field_name in ( " data_inicio " , " data_fim_prevista " ) :
normalized = self . _normalize_rental_datetime_text ( data . get ( field_name ) )
if normalized :
@ -268,6 +403,7 @@ class RentalFlowMixin:
return payload
# Enriquece o draft com placa, cpf, categoria, budget e datas extraidos da mensagem.
def _try_capture_rental_fields_from_message ( self , message : str , payload : dict ) - > None :
if payload . get ( " placa " ) is None :
words = re . findall ( r " [A-Za-z0-9-]+ " , str ( message or " " ) )
@ -292,6 +428,11 @@ class RentalFlowMixin:
if budget :
payload [ " valor_diaria_max " ] = float ( budget )
if payload . get ( " modelo " ) is None :
model_hint = self . _extract_rental_model_from_text ( message )
if model_hint :
payload [ " modelo " ] = model_hint
datetimes = self . _extract_rental_datetimes_from_text ( message )
if datetimes :
if not payload . get ( " data_inicio " ) :
@ -302,6 +443,7 @@ class RentalFlowMixin:
if payload [ " data_inicio " ] != datetimes [ 0 ] :
payload [ " data_fim_prevista " ] = datetimes [ 0 ]
# Detecta pedidos para listar a frota de aluguel.
def _has_rental_listing_request ( self , message : str , turn_decision : dict | None = None ) - > bool :
decision_intent = self . _decision_intent ( turn_decision )
decision_domain = str ( ( turn_decision or { } ) . get ( " domain " ) or " " ) . strip ( ) . lower ( )
@ -312,6 +454,7 @@ class RentalFlowMixin:
listing_terms = { " quais " , " listar " , " liste " , " mostrar " , " mostre " , " disponiveis " , " disponivel " , " frota " , " opcoes " , " opcao " }
return any ( term in normalized for term in rental_terms ) and any ( term in normalized for term in listing_terms )
# Detecta quando o usuario quer iniciar uma nova locacao.
def _has_explicit_rental_request ( self , message : str ) - > bool :
normalized = self . _normalize_text ( message ) . strip ( )
if any ( term in normalized for term in { " multa " , " comprovante " , " pagamento " , " devolucao " , " devolver " } ) :
@ -330,14 +473,17 @@ class RentalFlowMixin:
}
return any ( term in normalized for term in request_terms )
# Detecta pedidos de devolucao ou encerramento da locacao.
def _has_rental_return_request ( self , message : str ) - > bool :
normalized = self . _normalize_text ( message ) . strip ( )
return any ( term in normalized for term in { " devolver " , " devolucao " , " encerrar locacao " , " fechar locacao " } )
# Detecta quando a mensagem parece tratar de pagamento ou multa de aluguel.
def _has_rental_payment_or_fine_request ( self , message : str ) - > bool :
normalized = self . _normalize_text ( message ) . strip ( )
return any ( term in normalized for term in { " multa " , " comprovante " , " pagamento " , " boleto " , " pix " } )
# Interpreta selecoes numericas com base na ultima lista apresentada.
def _match_rental_vehicle_from_message_index ( self , message : str , rental_results : list [ dict ] ) - > dict | None :
tokens = [ token for token in re . findall ( r " \ d+ " , str ( message or " " ) ) if token . isdigit ( ) ]
if not tokens :
@ -347,6 +493,7 @@ class RentalFlowMixin:
return rental_results [ choice - 1 ]
return None
# Tenta casar a resposta do usuario com modelo ou placa da frota mostrada.
def _match_rental_vehicle_from_message_model ( self , message : str , rental_results : list [ dict ] ) - > dict | None :
normalized_message = self . _normalize_text ( message )
matches = [ ]
@ -361,6 +508,7 @@ class RentalFlowMixin:
return matches [ 0 ]
return None
# Resolve o veiculo escolhido reaproveitando contexto e texto livre.
def _try_resolve_rental_vehicle ( self , message : str , user_id : int | None , payload : dict ) - > dict | None :
rental_vehicle_id = payload . get ( " rental_vehicle_id " )
if isinstance ( rental_vehicle_id , int ) and rental_vehicle_id > 0 :
@ -385,6 +533,7 @@ class RentalFlowMixin:
return None
# Decide se a mensagem atual pode continuar uma selecao de aluguel ja iniciada.
def _should_bootstrap_rental_from_context ( self , message : str , user_id : int | None , payload : dict | None = None ) - > bool :
if user_id is None :
return False
@ -403,6 +552,7 @@ class RentalFlowMixin:
)
)
# Monta a pergunta objetiva com os campos que faltam para abrir a locacao.
def _render_missing_rental_fields_prompt ( self , missing_fields : list [ str ] ) - > str :
labels = {
" rental_vehicle_id " : " qual veiculo da frota voce quer alugar " ,
@ -412,6 +562,7 @@ class RentalFlowMixin:
items = [ f " - { labels [ field ] } " for field in missing_fields ]
return " Para abrir a locacao, preciso dos dados abaixo: \n " + " \n " . join ( items )
# Formata a lista curta da frota para o usuario escolher um veiculo.
def _render_rental_selection_from_fleet_prompt ( self , rental_results : list [ dict ] ) - > str :
lines = [ " Para seguir com a locacao, escolha primeiro qual veiculo voce quer alugar: " ]
for idx , item in enumerate ( rental_results [ : 10 ] , start = 1 ) :
@ -423,6 +574,7 @@ class RentalFlowMixin:
lines . append ( " Pode responder com o numero da lista, com a placa ou com o modelo. " )
return " \n " . join ( lines )
# Consulta a frota e guarda o resultado para a etapa de selecao.
async def _try_list_rental_fleet_for_selection (
self ,
message : str ,
@ -438,12 +590,17 @@ class RentalFlowMixin:
arguments : dict = {
" limite " : 10 ,
" ordenar_diaria " : " asc " ,
}
category = payload . get ( " categoria " ) or self . _extract_rental_category_from_text ( message )
if category :
arguments [ " categoria " ] = str ( category ) . strip ( ) . lower ( )
model_hint = str ( payload . get ( " modelo " ) or self . _extract_rental_model_from_text ( message ) or " " ) . strip ( )
if model_hint :
arguments [ " modelo " ] = model_hint
arguments [ " ordenar_diaria " ] = " asc " if ( category or model_hint ) else " random "
valor_diaria_max = payload . get ( " valor_diaria_max " )
if not isinstance ( valor_diaria_max , ( int , float ) ) :
valor_diaria_max = technical_normalizer . extract_budget_from_text ( message )
@ -464,6 +621,7 @@ class RentalFlowMixin:
self . _mark_rental_flow_active ( user_id = user_id )
return self . _fallback_format_tool_result ( " consultar_frota_aluguel " , tool_result )
# Conduz a coleta incremental dos dados e abre a locacao quando estiver completa.
async def _try_collect_and_open_rental (
self ,
message : str ,