@ -1,4 +1,5 @@
import re
import unicodedata
from datetime import datetime , timedelta
from fastapi import HTTPException
@ -11,7 +12,7 @@ from app.services.tool_registry import ToolRegistry
class OrquestradorService :
# Memoria temporaria de confirmacao quando a API sugere novo horario (conflito 409).
PENDING_REVIEW_CONFIRMATIONS : dict [ int , dict ] = { }
PENDING_REVIEW_TTL_MINUTES = 30 # Pode ser alterado por uma vari ável de configuração caso o sistema cresç a
PENDING_REVIEW_TTL_MINUTES = 30 # Pode ser alterado por uma vari avel de configuracao caso o sistema cresc a
# Rascunho por usuario para juntar dados de revisao enviados em mensagens separadas.
PENDING_REVIEW_DRAFTS : dict [ int , dict ] = { }
PENDING_REVIEW_DRAFT_TTL_MINUTES = 30
@ -34,6 +35,17 @@ class OrquestradorService:
" claro. " ,
" claro " ,
}
DETERMINISTIC_RESPONSE_TOOLS = {
" consultar_estoque " ,
" validar_cliente_venda " ,
" avaliar_veiculo_troca " ,
" agendar_revisao " ,
" listar_agendamentos_revisao " ,
" cancelar_agendamento_revisao " ,
" editar_data_revisao " ,
" cancelar_pedido " ,
" realizar_pedido " ,
}
def __init__ ( self , db : Session ) :
""" Inicializa servicos de LLM e registro de tools para a sessao atual. """
@ -42,6 +54,8 @@ class OrquestradorService:
async def handle_message ( self , message : str , user_id : int | None = None ) - > str :
""" Processa mensagem, executa tool quando necessario e retorna resposta final. """
routing_message = self . _resolve_primary_intent_message ( message = message , user_id = user_id )
# 1) Se houver sugestao pendente de horario e o usuario confirmou ("pode/sim"),
# agenda direto no horario sugerido.
confirmation_response = await self . _try_confirm_pending_review ( message = message , user_id = user_id )
@ -56,13 +70,13 @@ class OrquestradorService:
tools = self . registry . get_tools ( )
llm_result = await self . llm . generate_response (
message = self . _build_router_prompt ( user_message = message, user_id = user_id ) ,
message = self . _build_router_prompt ( user_message = routing_ message, user_id = user_id ) ,
tools = tools ,
)
if not llm_result [ " tool_call " ] and self . _is_operational_query ( message) :
if not llm_result [ " tool_call " ] and self . _is_operational_query ( routing_ message) :
llm_result = await self . llm . generate_response (
message = self . _build_force_tool_prompt ( user_message = message, user_id = user_id ) ,
message = self . _build_force_tool_prompt ( user_message = routing_ message, user_id = user_id ) ,
tools = tools ,
)
@ -85,6 +99,9 @@ class OrquestradorService:
)
return self . _http_exception_detail ( exc )
if self . _should_use_deterministic_response ( tool_name ) :
return self . _fallback_format_tool_result ( tool_name , tool_result )
final_response = await self . llm . generate_response (
message = self . _build_result_prompt (
user_message = message ,
@ -105,17 +122,97 @@ class OrquestradorService:
return " Entendi. Pode me dar mais detalhes para eu consultar corretamente? "
return text
def _reset_pending_review_states ( self , user_id : int | None ) - > None :
if user_id is None :
return
self . PENDING_REVIEW_DRAFTS . pop ( user_id , None )
self . PENDING_REVIEW_CONFIRMATIONS . pop ( user_id , None )
def _is_purchase_intent ( self , text : str ) - > bool :
lowered = self . _normalize_text ( text )
keywords = (
" comprar " ,
" compra " ,
" carro " ,
" carros " ,
" veiculo " ,
" veiculos " ,
" estoque " ,
)
return any ( k in lowered for k in keywords )
def _has_review_protocol ( self , text : str ) - > bool :
return re . search ( r " \ brev- \ d {8} -[a-z0-9]+ \ b " , ( text or " " ) . lower ( ) ) is not None
def _resolve_primary_intent_message ( self , message : str , user_id : int | None ) - > str :
# Em mensagens mistas ("cancele ... agora quero comprar"), prioriza compra
# quando nao ha protocolo explicito de revisao.
if not self . _is_purchase_intent ( message ) :
return message
if not self . _is_review_management_intent ( message ) :
return message
if self . _has_review_protocol ( message ) :
return message
lowered = self . _normalize_text ( message )
buy_markers = ( " agora quero comprar " , " quero comprar " , " comprar " , " compra " )
idx = - 1
for marker in buy_markers :
pos = lowered . rfind ( marker )
if pos > idx :
idx = pos
# Se identificar trecho de compra, usa apenas ele para rotear.
if idx > = 0 :
self . _reset_pending_review_states ( user_id = user_id )
return ( message or " " ) [ idx : ] . strip ( ) or message
return message
def _should_use_deterministic_response ( self , tool_name : str ) - > bool :
return tool_name in self . DETERMINISTIC_RESPONSE_TOOLS
def _normalize_text ( self , text : str ) - > str :
normalized = unicodedata . normalize ( " NFKD " , text or " " )
ascii_text = normalized . encode ( " ascii " , " ignore " ) . decode ( " ascii " )
return ascii_text . lower ( )
def _is_low_value_response ( self , text : str ) - > bool :
return text . strip ( ) . lower ( ) in self . LOW_VALUE_RESPONSES
def _is_review_intent ( self , text : str ) - > bool :
def _is_review_scheduling_intent ( self , text : str ) - > bool :
lowered = self . _normalize_text ( text )
scheduling_keywords = (
" agendar " ,
" marcar revis " ,
" marcar manutenc " ,
" nova revis " ,
" quero agendar " ,
" quero marcar " ,
)
return any ( k in lowered for k in scheduling_keywords )
def _is_review_management_intent ( self , text : str ) - > bool :
lowered = ( text or " " ) . lower ( )
return any ( k in lowered for k in ( " revis " , " manutenc " , " agendar " , " horario " ) )
management_keywords = (
" agendamento " ,
" agendamentos " ,
" meus agendamentos " ,
" listar " ,
" mostrar " ,
" ver " ,
" cancelar revis " ,
" cancelar agendamento " ,
" remarcar " ,
" editar data " ,
" alterar data " ,
)
return any ( k in lowered for k in management_keywords )
def _extract_review_fields ( self , text : str ) - > dict :
# Extrai os campos de revisao com regex simples para reduzir dependencia do LLM
# em mensagens curtas de follow-up.
lowered = ( text or " " ) . lower ( )
lowered = self . _normalize_text ( text )
extracted : dict = { }
placa_match = re . search ( r " \ b([A-Za-z] {3} [0-9][A-Za-z0-9][0-9] {2} |[A-Za-z] {3} [0-9] {4} ) \ b " , text or " " )
@ -123,23 +220,45 @@ class OrquestradorService:
extracted [ " placa " ] = placa_match . group ( 1 ) . upper ( )
dt_match = re . search (
r " ( \ d { 1,2}[/-] \ d { 1,2}[/-] \ d {4} \ s*(?:as |às )?\ s* \ d { 1,2}: \ d {2} )| "
r " ( \ d {4} [/-] \ d { 1,2}[/-] \ d { 1,2} \ s*(?:as |às )?\ s* \ d { 1,2}: \ d {2} )| "
r " ( \ d { 1,2}[/-] \ d { 1,2}[/-] \ d {4} \ s*(?:as )?\ s* \ d { 1,2}: \ d {2} )| "
r " ( \ d {4} [/-] \ d { 1,2}[/-] \ d { 1,2} \ s*(?:as )?\ s* \ d { 1,2}: \ d {2} )| "
r " ( \ d {4} - \ d {2} - \ d {2} T \ d {2} : \ d {2} (?:: \ d {2} )?(?:Z|[+-] \ d {2} : \ d {2} )?) " ,
lowered ,
)
if dt_match :
value = next ( ( g for g in dt_match . groups ( ) if g ) , None )
if value :
extracted [ " data_hora " ] = re . sub ( r " \ s+às \ s+ " , " as " , value , flags = re . IGNORECASE )
modelo_match = re . search ( r " modelo \ s+([a-z0-9][a-z0-9 \ s \ -] { 1,40}) " , lowered )
extracted [ " data_hora " ] = re . sub ( r " \ s+as \ s+ " , " as " , value , flags = re . IGNORECASE )
else :
day_ref = None
if re . search ( r " \ bhoje \ b " , lowered ) :
day_ref = " hoje "
elif re . search ( r " \ bamanh[a-z]? \ b " , lowered ) :
day_ref = " amanha "
if day_ref :
time_match = re . search ( r " \ b(?:as \ s*)?([01]? \ d|2[0-3])(?::([0-5] \ d))? \ b " , lowered )
if time_match :
hour = int ( time_match . group ( 1 ) )
minute = int ( time_match . group ( 2 ) or " 00 " )
target_date = datetime . now ( )
if day_ref == " amanha " :
target_date = target_date + timedelta ( days = 1 )
extracted [ " data_hora " ] = f " { target_date . strftime ( ' %d / % m/ % Y ' ) } { hour : 02d } : { minute : 02d } "
modelo_match = re . search (
r " modelo \ s+([a-z0-9][a-z0-9 \ s \ -] { 1,40}?)(?= \ s*(?:,|ano \ b| \ d { 1,3}(?:[. \ s] \ d {3} )* \ s*km \ b|$)) " ,
lowered ,
)
if modelo_match :
modelo = modelo_match . group ( 1 ) . strip ( " ,.; " )
if modelo :
extracted [ " modelo " ] = modelo . title ( )
ano_match = re . search ( r " (?:ano \ s*)?(19 \ d {2} |20 \ d {2} ) \ b " , lowered )
ano_match = re . search ( r " \ bano \ s*(?:de \ s*)?(19 \ d {2} |20 \ d {2} ) \ b " , lowered )
if not ano_match :
# Fallback sem a palavra "ano", evitando capturar o ano de uma data (ex.: 10/03/2026).
ano_match = re . search ( r " (?<![/-]) \ b(19 \ d {2} |20 \ d {2} ) \ b(?![/-]) " , lowered )
if ano_match :
extracted [ " ano " ] = int ( ano_match . group ( 1 ) )
@ -149,17 +268,24 @@ class OrquestradorService:
if km_text . isdigit ( ) :
extracted [ " km " ] = int ( km_text )
if any ( k in lowered for k in ( " ja fiz revisao " , " já fiz revisão " , " ja fez revisao " , " já fez revisão " ) ) :
if any (
k in lowered
for k in (
" ja fiz revisao " ,
" ja fez revisao " ,
" revisao previa na concessionaria " ,
" revisao previa em concessionaria " ,
)
) :
extracted [ " revisao_previa_concessionaria " ] = True
elif any (
k in lowered
for k in (
" nao fiz revisao " ,
" não fiz revisão " ,
" primeira revisao " ,
" primeira revisão " ,
" nunca fiz revisao " ,
" nunca fiz revisão " ,
" sem revisao previa na concessionaria " ,
" sem revisao previa em concessionaria " ,
)
) :
extracted [ " revisao_previa_concessionaria " ] = False
@ -178,11 +304,19 @@ class OrquestradorService:
itens = [ f " - { labels [ field ] } " for field in missing_fields ]
return " Para agendar sua revisao, preciso dos dados abaixo: \n " + " \n " . join ( itens )
# Em vez de tentar entender tudo de uma vez, o bot mant ém um "estado" do que já sabe e vai perguntando apenas o que falta (os "slots" vazios) até que a tarefa possa ser completada.
# Em vez de tentar entender tudo de uma vez, o bot mant em um "estado" do que ja sabe e vai perguntando apenas o que falta (os "slots" vazios) ate que a tarefa possa ser completada.
async def _try_collect_and_schedule_review ( self , message : str , user_id : int | None ) - > str | None :
if user_id is None :
return None
# Nao inicia slot-filling para fluxos de listar/cancelar/remarcar revisao.
# Nesses casos o roteamento via LLM + tools deve seguir normalmente.
if self . _is_review_management_intent ( message ) :
# Se o usuario mudou para gerenciamento de revisao, encerra
# qualquer coleta pendente de novo agendamento.
self . PENDING_REVIEW_DRAFTS . pop ( user_id , None )
return None
# Reaproveita rascunho anterior do usuario, se ainda estiver valido.
draft = self . PENDING_REVIEW_DRAFTS . get ( user_id )
if draft and draft [ " expires_at " ] < datetime . utcnow ( ) :
@ -190,7 +324,13 @@ class OrquestradorService:
draft = None
extracted = self . _extract_review_fields ( message )
has_intent = self . _is_review_intent ( message )
has_intent = self . _is_review_scheduling_intent ( message )
# Se houver rascunho de revisao, mas o usuario mudou para outra
# intencao operacional (ex.: compra/estoque), descarta o rascunho.
if draft and not has_intent and self . _is_operational_query ( message ) :
self . PENDING_REVIEW_DRAFTS . pop ( user_id , None )
return None
# Sem intencao de revisao e sem rascunho aberto: nao interfere no fluxo normal.
if not has_intent and draft is None :
@ -206,6 +346,16 @@ class OrquestradorService:
# Merge incremental: apenas atualiza os campos detectados na mensagem atual.
draft [ " payload " ] . update ( extracted )
# Se o usuario responder apenas "sim/nao" no follow-up, preenche o slot booleano.
if (
" revisao_previa_concessionaria " not in draft [ " payload " ]
and draft [ " payload " ]
and not extracted
) :
if self . _is_affirmative_message ( message ) :
draft [ " payload " ] [ " revisao_previa_concessionaria " ] = True
elif self . _is_negative_message ( message ) :
draft [ " payload " ] [ " revisao_previa_concessionaria " ] = False
draft [ " expires_at " ] = datetime . utcnow ( ) + timedelta ( minutes = self . PENDING_REVIEW_DRAFT_TTL_MINUTES )
self . PENDING_REVIEW_DRAFTS [ user_id ] = draft
@ -238,17 +388,16 @@ class OrquestradorService:
return self . _fallback_format_tool_result ( " agendar_revisao " , tool_result )
def _is_affirmative_message ( self , text : str ) - > bool :
normalized = ( text or " " ) . strip ( ) . lower ( )
normalized = self . _normalize_text ( text ) . strip ( )
normalized = re . sub ( r " [.!?,;:]+$ " , " " , normalized )
return normalized in { " sim " , " pode " , " ok " , " confirmo " , " aceito " , " fechado " , " pode sim " }
def _is_negative_message ( self , text : str ) - > bool :
normalized = ( text or " " ) . strip ( ) . lower ( )
normalized = self . _normalize_text ( text ) . strip ( )
normalized = re . sub ( r " [.!?,;:]+$ " , " " , normalized )
return (
normalized in { " nao " , " n ão" , " n ao quero" , " não quero" , " prefiro outro" , " outro hora rio" , " outro horá rio" }
normalized in { " nao " , " n ao quero" , " prefiro outro" , " outro hora rio" }
or normalized . startswith ( " nao " )
or normalized . startswith ( " não " )
)
def _extract_time_only ( self , text : str ) - > str | None :
@ -365,8 +514,12 @@ class OrquestradorService:
" cpf " ,
" troca " ,
" revis " ,
" agendamento " ,
" agendamentos " ,
" remarcar " ,
" placa " ,
" cancelar pedido " ,
" cancelar revisao " ,
" comprar " ,
" compra " ,
" realizar pedido " ,
@ -404,7 +557,8 @@ class OrquestradorService:
user_context = f " Contexto de usuario autenticado: user_id= { user_id } . \n " if user_id else " "
return (
" Responda ao usuario de forma objetiva usando o resultado da tool abaixo. "
" Nao invente dados. Se a lista vier vazia, diga explicitamente que nao encontrou resultados. \n \n "
" Nao invente dados. Se a lista vier vazia, diga explicitamente que nao encontrou resultados. "
" Retorne texto puro sem markdown, sem asteriscos, sem emojis e com linhas curtas. \n \n "
f " { user_context } "
f " Pergunta original: { user_message } \n "
f " Tool executada: { tool_name } \n "
@ -417,32 +571,134 @@ class OrquestradorService:
return detail
return " Nao foi possivel concluir a operacao solicitada. "
def _format_datetime_for_chat ( self , value : str ) - > str :
try :
dt = datetime . fromisoformat ( ( value or " " ) . replace ( " Z " , " +00:00 " ) )
return dt . strftime ( " %d / % m/ % Y % H: % M " )
except Exception :
return value or " N/A "
def _format_currency_br ( self , value ) - > str :
try :
number = float ( value )
formatted = f " { number : ,.2f } " . replace ( " , " , " X " ) . replace ( " . " , " , " ) . replace ( " X " , " . " )
return f " R$ { formatted } "
except Exception :
return " N/A "
def _fallback_format_tool_result ( self , tool_name : str , tool_result ) - > str :
if tool_name == " consultar_estoque " :
if tool_name == " consultar_estoque " and isinstance ( tool_result , list ) :
if not tool_result :
return " Nao encontrei nenhum veiculo com os criterios informados. "
return f " Encontrei { len ( tool_result ) } veiculo(s) com os criterios informados. "
linhas = [ f " Encontrei { len ( tool_result ) } veiculo(s): " ]
for idx , item in enumerate ( tool_result [ : 10 ] , start = 1 ) :
modelo = item . get ( " modelo " , " N/A " )
categoria = item . get ( " categoria " , " N/A " )
preco = self . _format_currency_br ( item . get ( " preco " ) )
linhas . append ( f " { idx } . { modelo } ( { categoria } ) - { preco } " )
restantes = len ( tool_result ) - 10
if restantes > 0 :
linhas . append ( f " ... e mais { restantes } veiculo(s). " )
return " \n " . join ( linhas )
if tool_name == " cancelar_pedido " and isinstance ( tool_result , dict ) :
numero = tool_result . get ( " numero_pedido " , " N/A " )
status = tool_result . get ( " status " , " N/A " )
return f " Pedido { numero } atualizado com status { status } . "
motivo = tool_result . get ( " motivo " )
linhas = [ f " Pedido { numero } atualizado. " , f " Status: { status } " ]
if motivo :
linhas . append ( f " Motivo: { motivo } " )
return " \n " . join ( linhas )
if tool_name == " realizar_pedido " and isinstance ( tool_result , dict ) :
numero = tool_result . get ( " numero_pedido " , " N/A " )
return f " Pedido { numero } criado com sucesso. "
valor = self . _format_currency_br ( tool_result . get ( " valor_veiculo " ) )
return f " Pedido criado com sucesso. \n Numero: { numero } \n Valor: { valor } "
if tool_name == " agendar_revisao " and isinstance ( tool_result , dict ) :
placa = tool_result . get ( " placa " , " N/A " )
data_hora = tool_result . get ( " data_hora " , " N/A " )
data_hora = self . _format_datetime_for_chat ( tool_result . get ( " data_hora " , " N/A " ) )
protocolo = tool_result . get ( " protocolo " , " N/A " )
valor = tool_result . get ( " valor_revisao " )
if isinstance ( valor , ( int , float ) ) :
return f " Revisao agendada para placa { placa } em { data_hora } . Valor estimado: R$ { valor : .2f } . Protocolo: { protocolo } . "
return f " Revisao agendada para placa { placa } em { data_hora } . Protocolo: { protocolo } . "
return (
" Revisao agendada com sucesso. \n "
f " Protocolo: { protocolo } \n "
f " Placa: { placa } \n "
f " Data/Hora: { data_hora } \n "
f " Valor estimado: { self . _format_currency_br ( valor ) } "
)
return (
" Revisao agendada com sucesso. \n "
f " Protocolo: { protocolo } \n "
f " Placa: { placa } \n "
f " Data/Hora: { data_hora } "
)
if tool_name == " listar_agendamentos_revisao " and isinstance ( tool_result , list ) :
if not tool_result :
return " Nao encontrei agendamentos de revisao para sua conta. "
linhas = [ f " Voce tem { len ( tool_result ) } agendamento(s): " ]
for idx , item in enumerate ( tool_result [ : 12 ] , start = 1 ) :
protocolo = item . get ( " protocolo " , " N/A " )
placa = item . get ( " placa " , " N/A " )
data_hora = self . _format_datetime_for_chat ( item . get ( " data_hora " , " N/A " ) )
status = item . get ( " status " , " N/A " )
linhas . append ( f " { idx } ) Protocolo: { protocolo } " )
linhas . append ( f " Placa: { placa } " )
linhas . append ( f " Data/Hora: { data_hora } | Status: { status } " )
if idx < min ( len ( tool_result ) , 12 ) :
linhas . append ( " " )
restantes = len ( tool_result ) - 12
if restantes > 0 :
if linhas and linhas [ - 1 ] != " " :
linhas . append ( " " )
linhas . append ( f " ... e mais { restantes } agendamento(s). " )
return " \n " . join ( linhas )
if tool_name == " cancelar_agendamento_revisao " and isinstance ( tool_result , dict ) :
protocolo = tool_result . get ( " protocolo " , " N/A " )
status = tool_result . get ( " status " , " N/A " )
placa = tool_result . get ( " placa " , " N/A " )
data_hora = self . _format_datetime_for_chat ( tool_result . get ( " data_hora " , " N/A " ) )
return (
" Agendamento atualizado. \n "
f " Protocolo: { protocolo } \n "
f " Placa: { placa } \n "
f " Data/Hora: { data_hora } \n "
f " Status: { status } "
)
if tool_name == " editar_data_revisao " and isinstance ( tool_result , dict ) :
protocolo = tool_result . get ( " protocolo " , " N/A " )
placa = tool_result . get ( " placa " , " N/A " )
data_hora = self . _format_datetime_for_chat ( tool_result . get ( " data_hora " , " N/A " ) )
status = tool_result . get ( " status " , " N/A " )
return (
" Agendamento remarcado com sucesso. \n "
f " Protocolo: { protocolo } \n "
f " Placa: { placa } \n "
f " Nova data/hora: { data_hora } \n "
f " Status: { status } "
)
if tool_name == " validar_cliente_venda " and isinstance ( tool_result , dict ) :
aprovado = tool_result . get ( " aprovado " )
return " Cliente aprovado para financiamento. " if aprovado else " Cliente nao aprovado para financiamento. "
limite = self . _format_currency_br ( tool_result . get ( " limite_credito " ) )
score = tool_result . get ( " score " , " N/A " )
cpf = tool_result . get ( " cpf " , " N/A " )
if aprovado :
return (
" Cliente aprovado para financiamento. \n "
f " CPF: { cpf } \n "
f " Score: { score } \n "
f " Limite: { limite } "
)
return (
" Cliente nao aprovado para financiamento. \n "
f " CPF: { cpf } \n "
f " Score: { score } \n "
f " Limite: { limite } "
)
return " Operacao concluida com sucesso. "