@ -11,6 +11,7 @@ from app.services.orchestrator_config import (
CANCEL_ORDER_REQUIRED_FIELDS ,
DETERMINISTIC_RESPONSE_TOOLS ,
LOW_VALUE_RESPONSES ,
LAST_REVIEW_PACKAGE_TTL_MINUTES ,
ORDER_REQUIRED_FIELDS ,
PENDING_CANCEL_ORDER_DRAFT_TTL_MINUTES ,
PENDING_ORDER_DRAFT_TTL_MINUTES ,
@ -32,6 +33,9 @@ class OrquestradorService:
PENDING_REVIEW_CONFIRMATIONS : dict [ int , dict ] = { }
# Rascunho por usuario para juntar dados de revisao enviados em mensagens separadas.
PENDING_REVIEW_DRAFTS : dict [ int , dict ] = { }
PENDING_REVIEW_MANAGEMENT_DRAFTS : dict [ int , dict ] = { }
LAST_REVIEW_PACKAGES : dict [ int , dict ] = { }
PENDING_REVIEW_REUSE_CONFIRMATIONS : dict [ int , dict ] = { }
PENDING_ORDER_DRAFTS : dict [ int , dict ] = { }
PENDING_CANCEL_ORDER_DRAFTS : dict [ int , dict ] = { }
@ -54,8 +58,23 @@ class OrquestradorService:
)
self . _upsert_user_context ( user_id = user_id )
queued_followup = await self . _try_continue_queued_order ( message = message , user_id = user_id )
if queued_followup :
return queued_followup
routing_plan = await self . _extract_routing_with_llm ( message = message , user_id = user_id )
message_plan = await self . _extract_message_plan_with_llm (
message = message ,
user_id = user_id ,
)
routing_plan = {
" orders " : [
{
" domain " : item . get ( " domain " , " general " ) ,
" message " : item . get ( " message " , " " ) ,
}
for item in message_plan . get ( " orders " , [ ] )
]
}
(
routing_message ,
@ -69,10 +88,15 @@ class OrquestradorService:
if queue_early_response :
return await finish ( queue_early_response , queue_notice = queue_notice )
extracted_entities = await self . _extract_entities_with_llm (
message = routing_ message,
user_id= user_id ,
extracted_entities = self . _resolve_entities_for_message_plan (
message _plan = message_plan ,
routed_message= routing_message ,
)
if not self . _has_useful_extraction ( extracted_entities ) :
extracted_entities = await self . _extract_entities_with_llm (
message = routing_message ,
user_id = user_id ,
)
self . _capture_generic_memory (
user_id = user_id ,
llm_generic_fields = extracted_entities . get ( " generic_memory " , { } ) ,
@ -92,6 +116,7 @@ class OrquestradorService:
review_management_response = await self . _try_handle_review_management (
message = routing_message ,
user_id = user_id ,
extracted_fields = extracted_entities . get ( " review_management_fields " , { } ) ,
intents = extracted_entities . get ( " intents " , { } ) ,
)
if review_management_response :
@ -142,7 +167,13 @@ class OrquestradorService:
tools = tools ,
)
if not llm_result [ " tool_call " ] and self . _has_operational_intent ( extracted_entities ) :
first_pass_text = ( llm_result . get ( " response " ) or " " ) . strip ( )
should_force_tool = (
not llm_result [ " tool_call " ]
and self . _has_operational_intent ( extracted_entities )
and self . _is_low_value_response ( first_pass_text )
)
if should_force_tool :
llm_result = await self . llm . generate_response (
message = self . _build_force_tool_prompt ( user_message = routing_message , user_id = user_id ) ,
tools = tools ,
@ -207,6 +238,8 @@ class OrquestradorService:
return
self . PENDING_REVIEW_DRAFTS . pop ( user_id , None )
self . PENDING_REVIEW_CONFIRMATIONS . pop ( user_id , None )
self . PENDING_REVIEW_MANAGEMENT_DRAFTS . pop ( user_id , None )
self . PENDING_REVIEW_REUSE_CONFIRMATIONS . pop ( user_id , None )
def _reset_pending_order_states ( self , user_id : int | None ) - > None :
if user_id is None :
@ -293,11 +326,54 @@ class OrquestradorService:
return {
" generic_memory " : { } ,
" review_fields " : { } ,
" review_management_fields " : { } ,
" order_fields " : { } ,
" cancel_order_fields " : { } ,
" intents " : { } ,
}
def _empty_message_plan ( self , message : str ) - > dict :
return {
" orders " : [
{
" domain " : " general " ,
" message " : ( message or " " ) . strip ( ) ,
" entities " : self . _empty_extraction_payload ( ) ,
}
]
}
def _coerce_message_plan ( self , payload , message : str ) - > dict :
default = self . _empty_message_plan ( message = message )
if not isinstance ( payload , dict ) :
return default
raw_orders = payload . get ( " orders " )
if not isinstance ( raw_orders , list ) :
return default
normalized_orders : list [ dict ] = [ ]
for item in raw_orders :
if not isinstance ( item , dict ) :
continue
domain = str ( item . get ( " domain " ) or " general " ) . strip ( ) . lower ( )
if domain not in { " review " , " sales " , " general " } :
domain = " general "
segment = str ( item . get ( " message " ) or " " ) . strip ( )
if not segment :
continue
normalized_orders . append (
{
" domain " : domain ,
" message " : segment ,
" entities " : self . _coerce_extraction_contract ( item . get ( " entities " ) ) ,
}
)
if not normalized_orders :
return default
return { " orders " : normalized_orders }
def _coerce_extraction_contract ( self , payload ) - > dict :
if not isinstance ( payload , dict ) :
return self . _empty_extraction_payload ( )
@ -309,14 +385,25 @@ class OrquestradorService:
logger . info ( " Extracao sem secao ' %s ' ; usando vazio. " , key )
return contract
async def _extract_ routing _with_llm( self , message : str , user_id : int | None ) - > dict :
async def _extract_ message_plan _with_llm( self , message : str , user_id : int | None ) - > dict :
prompt = (
" Analise a mensagem e retorne APENAS JSON valido para roteamento multiassunt o.\n "
" Analise a mensagem e retorne APENAS JSON valido com roteamento e entidades por pedid o.\n "
" Sem markdown e sem texto extra. \n \n "
" Formato: \n "
" { \n "
' " orders " : [ \n '
' { " domain " : " review|sales|general " , " message " : " trecho literal do pedido " } \n '
" { \n "
' " domain " : " review|sales|general " , \n '
' " message " : " trecho literal do pedido " , \n '
' " entities " : { \n '
' " generic_memory " : { " placa " : null, " cpf " : null, " orcamento_max " : null, " perfil_veiculo " : []}, \n '
' " review_fields " : { " placa " : null, " data_hora " : null, " modelo " : null, " ano " : null, " km " : null, " revisao_previa_concessionaria " : null}, \n '
' " review_management_fields " : { " protocolo " : null, " nova_data_hora " : null, " motivo " : null}, \n '
' " order_fields " : { " cpf " : null, " valor_veiculo " : null}, \n '
' " cancel_order_fields " : { " numero_pedido " : null, " motivo " : null}, \n '
' " intents " : { " review_schedule " : false, " review_list " : false, " review_cancel " : false, " review_reschedule " : false, " order_create " : false, " order_cancel " : false} \n '
" } \n "
" } \n "
" ] \n "
" } \n \n "
" Regras: \n "
@ -326,34 +413,31 @@ class OrquestradorService:
f " Contexto: user_id= { user_id if user_id is not None else ' anonimo ' } \n "
f " Mensagem do usuario: { message } "
)
default = { " orders " : [ { " domain " : " general " , " message " : ( message or " " ) . strip ( ) } ] }
default = self . _empty_message_plan ( message = message )
try :
result = await self . llm . generate_response ( message = prompt , tools = [ ] )
text = ( result . get ( " response " ) or " " ) . strip ( )
payload = self . _parse_json_object ( text )
if not isinstance ( payload , dict ) :
logger . warning ( " Roteamento invalido (nao JSON objeto). user_id=%s " , user_id )
logger . warning ( " Plano de mensagem invalido (nao JSON objeto). user_id=%s " , user_id )
return default
orders = payload . get ( " orders " )
if not isinstance ( orders , list ) :
return default
normalized : list [ dict ] = [ ]
for item in orders :
if not isinstance ( item , dict ) :
continue
domain = str ( item . get ( " domain " ) or " general " ) . strip ( ) . lower ( )
if domain not in { " review " , " sales " , " general " } :
domain = " general "
segment = str ( item . get ( " message " ) or " " ) . strip ( )
if segment :
normalized . append ( { " domain " : domain , " message " : segment } )
if not normalized :
return default
return { " orders " : normalized }
return self . _coerce_message_plan ( payload = payload , message = message )
except Exception :
logger . exception ( " Falha ao rotear multiassunto com LLM. user_id=%s " , user_id )
logger . exception ( " Falha ao extrair plano da mensagem com LLM. user_id= %s " , user_id )
return default
async def _extract_routing_with_llm ( self , message : str , user_id : int | None ) - > dict :
plan = await self . _extract_message_plan_with_llm ( message = message , user_id = user_id )
return {
" orders " : [
{
" domain " : item . get ( " domain " , " general " ) ,
" message " : item . get ( " message " , " " ) ,
}
for item in plan . get ( " orders " , [ ] )
]
}
async def _extract_entities_with_llm ( self , message : str , user_id : int | None ) - > dict :
user_context = f " user_id= { user_id } " if user_id is not None else " user_id=anonimo "
prompt = (
@ -376,6 +460,11 @@ class OrquestradorService:
' " km " : null, \n '
' " revisao_previa_concessionaria " : null \n '
" }, \n "
' " review_management_fields " : { \n '
' " protocolo " : null, \n '
' " nova_data_hora " : null, \n '
' " motivo " : null \n '
" }, \n "
' " order_fields " : { \n '
' " cpf " : null, \n '
' " valor_veiculo " : null \n '
@ -387,6 +476,8 @@ class OrquestradorService:
' " intents " : { \n '
' " review_schedule " : false, \n '
' " review_list " : false, \n '
' " review_cancel " : false, \n '
' " review_reschedule " : false, \n '
' " order_create " : false, \n '
' " order_cancel " : false \n '
" } \n "
@ -410,6 +501,7 @@ class OrquestradorService:
return {
" generic_memory " : self . _normalize_generic_fields ( coerced . get ( " generic_memory " ) ) ,
" review_fields " : self . _normalize_review_fields ( coerced . get ( " review_fields " ) ) ,
" review_management_fields " : self . _normalize_review_management_fields ( coerced . get ( " review_management_fields " ) ) ,
" order_fields " : self . _normalize_order_fields ( coerced . get ( " order_fields " ) ) ,
" cancel_order_fields " : self . _normalize_cancel_order_fields ( coerced . get ( " cancel_order_fields " ) ) ,
" intents " : self . _normalize_intents ( coerced . get ( " intents " ) ) ,
@ -418,6 +510,44 @@ class OrquestradorService:
logger . exception ( " Falha ao extrair entidades com LLM. user_id= %s " , user_id )
return default
def _resolve_entities_for_message_plan ( self , message_plan : dict , routed_message : str ) - > dict :
default = self . _empty_extraction_payload ( )
if not isinstance ( message_plan , dict ) :
return default
target = ( routed_message or " " ) . strip ( )
raw_orders = message_plan . get ( " orders " )
if not isinstance ( raw_orders , list ) :
return default
for item in raw_orders :
if not isinstance ( item , dict ) :
continue
segment = str ( item . get ( " message " ) or " " ) . strip ( )
if segment != target :
continue
entities = self . _coerce_extraction_contract ( item . get ( " entities " ) )
return {
" generic_memory " : self . _normalize_generic_fields ( entities . get ( " generic_memory " ) ) ,
" review_fields " : self . _normalize_review_fields ( entities . get ( " review_fields " ) ) ,
" review_management_fields " : self . _normalize_review_management_fields ( entities . get ( " review_management_fields " ) ) ,
" order_fields " : self . _normalize_order_fields ( entities . get ( " order_fields " ) ) ,
" cancel_order_fields " : self . _normalize_cancel_order_fields ( entities . get ( " cancel_order_fields " ) ) ,
" intents " : self . _normalize_intents ( entities . get ( " intents " ) ) ,
}
return default
def _has_useful_extraction ( self , extraction : dict | None ) - > bool :
if not isinstance ( extraction , dict ) :
return False
intents = self . _normalize_intents ( extraction . get ( " intents " ) )
if any ( intents . values ( ) ) :
return True
return any (
bool ( extraction . get ( key ) )
for key in ( " generic_memory " , " review_fields " , " review_management_fields " , " order_fields " , " cancel_order_fields " )
)
def _parse_json_object ( self , text : str ) :
candidate = ( text or " " ) . strip ( )
if not candidate :
@ -573,6 +703,35 @@ class OrquestradorService:
extracted [ " revisao_previa_concessionaria " ] = reviewed
return extracted
def _extract_review_protocol_from_text ( self , text : str ) - > str | None :
match = re . search ( r " \ bREV-[A-Z0-9 \ -]+ \ b " , str ( text or " " ) . upper ( ) )
if not match :
return None
return match . group ( 0 )
def _normalize_review_management_fields ( self , data ) - > dict :
if not isinstance ( data , dict ) :
return { }
extracted : dict = { }
raw_protocol = (
data . get ( " protocolo " )
or data . get ( " numero_protocolo " )
or data . get ( " codigo " )
)
protocol = self . _extract_review_protocol_from_text ( str ( raw_protocol or " " ) )
if protocol :
extracted [ " protocolo " ] = protocol
new_datetime = self . _normalize_review_datetime_text ( data . get ( " nova_data_hora " ) )
if new_datetime :
extracted [ " nova_data_hora " ] = new_datetime
reason = str ( data . get ( " motivo " ) or " " ) . strip ( " .; " )
if reason :
extracted [ " motivo " ] = reason
return extracted
def _normalize_order_fields ( self , data ) - > dict :
if not isinstance ( data , dict ) :
return { }
@ -603,6 +762,8 @@ class OrquestradorService:
return {
" review_schedule " : bool ( self . _normalize_bool ( data . get ( " review_schedule " ) ) ) ,
" review_list " : bool ( self . _normalize_bool ( data . get ( " review_list " ) ) ) ,
" review_cancel " : bool ( self . _normalize_bool ( data . get ( " review_cancel " ) ) ) ,
" review_reschedule " : bool ( self . _normalize_bool ( data . get ( " review_reschedule " ) ) ) ,
" order_create " : bool ( self . _normalize_bool ( data . get ( " order_create " ) ) ) ,
" order_cancel " : bool ( self . _normalize_bool ( data . get ( " order_cancel " ) ) ) ,
}
@ -615,7 +776,7 @@ class OrquestradorService:
return True
return any (
bool ( extracted_entities . get ( key ) )
for key in ( " review_fields " , " order_fields" , " cancel_order_fields " )
for key in ( " review_fields " , " review_management_fields" , " order_fields" , " cancel_order_fields " )
)
def _try_prefill_review_fields_from_memory ( self , user_id : int | None , payload : dict ) - > None :
@ -691,29 +852,43 @@ class OrquestradorService:
and self . _has_open_flow ( user_id = user_id , domain = active_domain )
) :
self . _queue_order ( user_id = user_id , domain = inferred , order_message = message )
queue_hint = self . _render_queue_notice ( 1 )
return (
message ,
None ,
self . _render_open_flow_prompt ( user_id = user_id , domain = active_domain ) ,
(
f " { self . _render_open_flow_prompt ( user_id = user_id , domain = active_domain ) } \n { queue_hint } "
if queue_hint
else self . _render_open_flow_prompt ( user_id = user_id , domain = active_domain )
) ,
)
return message , None , None
if self . _has_open_flow ( user_id = user_id , domain = active_domain ) :
queued_count = 0
for queued in extracted_orders :
if queued [ " domain " ] != active_domain :
self . _queue_order ( user_id = user_id , domain = queued [ " domain " ] , order_message = queued [ " message " ] )
queued_count + = 1
queue_hint = self . _render_queue_notice ( queued_count )
return (
message ,
None ,
self . _render_open_flow_prompt ( user_id = user_id , domain = active_domain ) ,
(
f " { self . _render_open_flow_prompt ( user_id = user_id , domain = active_domain ) } \n { queue_hint } "
if queue_hint
else self . _render_open_flow_prompt ( user_id = user_id , domain = active_domain )
) ,
)
first = extracted_orders [ 0 ]
queued_count = 0
for queued in extracted_orders [ 1 : ] :
self . _queue_order ( user_id = user_id , domain = queued [ " domain " ] , order_message = queued [ " message " ] )
queued_count + = 1
context [ " active_domain " ] = first [ " domain " ]
queue_notice = None
queue_notice = self . _render_queue_notice ( queued_count )
return first [ " message " ] , queue_notice , None
def _compose_order_aware_response ( self , response : str , user_id : int | None , queue_notice : str | None = None ) - > str :
@ -723,6 +898,13 @@ class OrquestradorService:
lines . append ( response )
return " \n " . join ( lines )
def _render_queue_notice ( self , queued_count : int ) - > str | None :
if queued_count < = 0 :
return None
if queued_count == 1 :
return " Anotei mais 1 pedido e sigo nele quando voce disser ' continuar ' . "
return f " Anotei mais { queued_count } pedidos e sigo neles conforme voce for dizendo ' continuar ' . "
def _render_open_flow_prompt ( self , user_id : int | None , domain : str ) - > str :
if domain == " review " and user_id is not None :
draft = self . PENDING_REVIEW_DRAFTS . get ( user_id )
@ -731,9 +913,25 @@ class OrquestradorService:
if missing :
return self . _render_missing_review_fields_prompt ( missing )
management_draft = self . PENDING_REVIEW_MANAGEMENT_DRAFTS . get ( user_id )
if management_draft :
action = management_draft . get ( " action " , " cancel " )
payload = management_draft . get ( " payload " , { } )
if action == " reschedule " :
missing = [ field for field in ( " protocolo " , " nova_data_hora " ) if field not in payload ]
if missing :
return self . _render_missing_review_reschedule_fields_prompt ( missing )
else :
missing = [ field for field in ( " protocolo " , ) if field not in payload ]
if missing :
return self . _render_missing_review_cancel_fields_prompt ( missing )
pending = self . PENDING_REVIEW_CONFIRMATIONS . get ( user_id )
if pending :
return " Antes de mudar de assunto, me confirme se podemos concluir seu agendamento de revisao. "
reuse_pending = self . PENDING_REVIEW_REUSE_CONFIRMATIONS . get ( user_id )
if reuse_pending :
return self . _render_review_reuse_question ( )
if domain == " sales " and user_id is not None :
draft = self . PENDING_ORDER_DRAFTS . get ( user_id )
if draft :
@ -771,16 +969,28 @@ class OrquestradorService:
if not next_order :
return base_response
context [ " active_domain " ] = next_order [ " domain " ]
context [ " generic_memory " ] = dict ( next_order . get ( " memory_seed " ) or self . _new_tab_memory ( user_id = user_id ) )
context [ " pending_switch " ] = None
next_response = await self . handle_message ( next_order [ " message " ] , user_id = user_id )
context [ " pending_switch " ] = {
" source_domain " : context . get ( " active_domain " , " general " ) ,
" target_domain " : next_order [ " domain " ] ,
" queued_message " : next_order [ " message " ] ,
" memory_seed " : dict ( next_order . get ( " memory_seed " ) or self . _new_tab_memory ( user_id = user_id ) ) ,
" expires_at " : datetime . utcnow ( ) + timedelta ( minutes = 15 ) ,
}
transition = self . _build_next_order_transition ( next_order [ " domain " ] )
return f " { base_response } \n \n { transition } \n { next_response } "
return (
f " { base_response } \n \n "
f " { transition } \n "
" Tenho um proximo pedido na fila. Quando quiser, diga ' continuar ' para eu seguir nele. "
)
def _domain_from_intents ( self , intents : dict | None ) - > str :
normalized = self . _normalize_intents ( intents )
review_score = int ( normalized . get ( " review_schedule " , False ) ) + int ( normalized . get ( " review_list " , False ) )
review_score = (
int ( normalized . get ( " review_schedule " , False ) )
+ int ( normalized . get ( " review_list " , False ) )
+ int ( normalized . get ( " review_cancel " , False ) )
+ int ( normalized . get ( " review_reschedule " , False ) )
)
sales_score = int ( normalized . get ( " order_create " , False ) ) + int ( normalized . get ( " order_cancel " , False ) )
if review_score > sales_score and review_score > 0 :
return " review "
@ -791,6 +1001,43 @@ class OrquestradorService:
def _is_context_switch_confirmation ( self , message : str ) - > bool :
return self . _is_affirmative_message ( message ) or self . _is_negative_message ( message )
def _is_continue_queue_message ( self , message : str ) - > bool :
normalized = self . _normalize_text ( message ) . strip ( )
normalized = re . sub ( r " [.!?,;:]+$ " , " " , normalized )
return normalized in { " continuar " , " pode continuar " , " seguir " , " pode seguir " , " proximo " , " segue " }
async def _try_continue_queued_order ( self , message : str , user_id : int | None ) - > str | None :
context = self . _get_user_context ( user_id )
if not context :
return None
pending_switch = context . get ( " pending_switch " )
if not isinstance ( pending_switch , dict ) :
return None
if pending_switch . get ( " expires_at " ) and pending_switch [ " expires_at " ] < datetime . utcnow ( ) :
context [ " pending_switch " ] = None
return None
queued_message = str ( pending_switch . get ( " queued_message " ) or " " ) . strip ( )
if not queued_message :
return None
if self . _is_negative_message ( message ) :
context [ " pending_switch " ] = None
return " Tudo bem. Mantive o proximo pedido fora da fila por enquanto. "
if not ( self . _is_continue_queue_message ( message ) or self . _is_affirmative_message ( message ) ) :
return None
target_domain = str ( pending_switch . get ( " target_domain " ) or " general " )
memory_seed = dict ( pending_switch . get ( " memory_seed " ) or { } )
self . _apply_domain_switch ( user_id = user_id , target_domain = target_domain )
refreshed = self . _get_user_context ( user_id )
if refreshed is not None :
refreshed [ " generic_memory " ] = memory_seed
transition = self . _build_next_order_transition ( target_domain )
next_response = await self . handle_message ( queued_message , user_id = user_id )
return f " { transition } \n { next_response } "
def _has_open_flow ( self , user_id : int | None , domain : str ) - > bool :
if user_id is None :
return False
@ -798,6 +1045,8 @@ class OrquestradorService:
return bool (
self . PENDING_REVIEW_DRAFTS . get ( user_id )
or self . PENDING_REVIEW_CONFIRMATIONS . get ( user_id )
or self . PENDING_REVIEW_MANAGEMENT_DRAFTS . get ( user_id )
or self . PENDING_REVIEW_REUSE_CONFIRMATIONS . get ( user_id )
)
if domain == " sales " :
return bool (
@ -913,25 +1162,107 @@ class OrquestradorService:
self ,
message : str ,
user_id : int | None ,
extracted_fields : dict | None = None ,
intents : dict | None = None ,
) - > str | None :
if user_id is None :
return None
normalized_intents = self . _normalize_intents ( intents )
if not normalized_intents . get ( " review_list " , False ) :
draft = self . PENDING_REVIEW_MANAGEMENT_DRAFTS . get ( user_id )
if draft and draft [ " expires_at " ] < datetime . utcnow ( ) :
self . PENDING_REVIEW_MANAGEMENT_DRAFTS . pop ( user_id , None )
draft = None
has_list_intent = normalized_intents . get ( " review_list " , False )
has_cancel_intent = normalized_intents . get ( " review_cancel " , False )
has_reschedule_intent = normalized_intents . get ( " review_reschedule " , False )
if has_list_intent :
# Listagem e acao terminal; limpa rascunhos de revisao para evitar conflito de contexto.
self . _reset_pending_review_states ( user_id = user_id )
try :
tool_result = await self . registry . execute (
" listar_agendamentos_revisao " ,
{ " limite " : 20 } ,
user_id = user_id ,
)
except HTTPException as exc :
return self . _http_exception_detail ( exc )
return self . _fallback_format_tool_result ( " listar_agendamentos_revisao " , tool_result )
if not has_cancel_intent and not has_reschedule_intent and draft is None :
return None
# Se o usuario pediu listagem, encerramos coleta pendente para nao competir com o fluxo.
self . _reset_pending_review_states ( user_id = user_id )
if draft is None :
action = " reschedule " if has_reschedule_intent else " cancel "
draft = {
" action " : action ,
" payload " : { } ,
" expires_at " : datetime . utcnow ( ) + timedelta ( minutes = PENDING_REVIEW_DRAFT_TTL_MINUTES ) ,
}
else :
if has_reschedule_intent :
draft [ " action " ] = " reschedule "
elif has_cancel_intent :
draft [ " action " ] = " cancel "
extracted = self . _normalize_review_management_fields ( extracted_fields )
if " protocolo " not in extracted :
inferred_protocol = self . _extract_review_protocol_from_text ( message )
if inferred_protocol :
extracted [ " protocolo " ] = inferred_protocol
action = draft . get ( " action " , " cancel " )
if (
action == " cancel "
and " motivo " not in extracted
and draft [ " payload " ] . get ( " protocolo " )
and not has_cancel_intent
) :
free_text = str ( message or " " ) . strip ( )
if free_text and len ( free_text ) > = 4 and not self . _is_affirmative_message ( free_text ) :
extracted [ " motivo " ] = free_text
draft [ " payload " ] . update ( extracted )
draft [ " expires_at " ] = datetime . utcnow ( ) + timedelta ( minutes = PENDING_REVIEW_DRAFT_TTL_MINUTES )
self . PENDING_REVIEW_MANAGEMENT_DRAFTS [ user_id ] = draft
if action == " reschedule " :
missing = [ field for field in ( " protocolo " , " nova_data_hora " ) if field not in draft [ " payload " ] ]
if missing :
return self . _render_missing_review_reschedule_fields_prompt ( missing )
try :
tool_result = await self . registry . execute (
" editar_data_revisao " ,
{
" protocolo " : draft [ " payload " ] [ " protocolo " ] ,
" nova_data_hora " : draft [ " payload " ] [ " nova_data_hora " ] ,
} ,
user_id = user_id ,
)
except HTTPException as exc :
return self . _http_exception_detail ( exc )
finally :
self . PENDING_REVIEW_MANAGEMENT_DRAFTS . pop ( user_id , None )
return self . _fallback_format_tool_result ( " editar_data_revisao " , tool_result )
missing = [ field for field in ( " protocolo " , ) if field not in draft [ " payload " ] ]
if missing :
return self . _render_missing_review_cancel_fields_prompt ( missing )
try :
tool_result = await self . registry . execute (
" listar_agendamentos_revisao " ,
{ " limite " : 20 } ,
" cancelar_agendamento_revisao " ,
{
" protocolo " : draft [ " payload " ] [ " protocolo " ] ,
" motivo " : draft [ " payload " ] . get ( " motivo " ) ,
} ,
user_id = user_id ,
)
except HTTPException as exc :
return self . _http_exception_detail ( exc )
return self . _fallback_format_tool_result ( " listar_agendamentos_revisao " , tool_result )
finally :
self . PENDING_REVIEW_MANAGEMENT_DRAFTS . pop ( user_id , None )
return self . _fallback_format_tool_result ( " cancelar_agendamento_revisao " , tool_result )
def _render_missing_review_fields_prompt ( self , missing_fields : list [ str ] ) - > str :
labels = {
@ -945,6 +1276,58 @@ class OrquestradorService:
itens = [ f " - { labels [ field ] } " for field in missing_fields ]
return " Para agendar sua revisao, preciso dos dados abaixo: \n " + " \n " . join ( itens )
def _render_missing_review_cancel_fields_prompt ( self , missing_fields : list [ str ] ) - > str :
labels = {
" protocolo " : " o protocolo da revisao (ex.: REV-20260310-ABC12345) " ,
}
itens = [ f " - { labels [ field ] } " for field in missing_fields ]
return " Para cancelar o agendamento de revisao, preciso dos dados abaixo: \n " + " \n " . join ( itens )
def _render_missing_review_reschedule_fields_prompt ( self , missing_fields : list [ str ] ) - > str :
labels = {
" protocolo " : " o protocolo da revisao (ex.: REV-20260310-ABC12345) " ,
" nova_data_hora " : " a nova data e hora desejada para a revisao " ,
}
itens = [ f " - { labels [ field ] } " for field in missing_fields ]
return " Para remarcar sua revisao, preciso dos dados abaixo: \n " + " \n " . join ( itens )
def _render_review_reuse_question ( self ) - > str :
return (
" Deseja usar os mesmos dados do ultimo veiculo e informar so a data/hora da revisao? "
" (sim/nao) "
)
def _store_last_review_package ( self , user_id : int | None , payload : dict | None ) - > None :
if user_id is None or not isinstance ( payload , dict ) :
return
package = {
" placa " : payload . get ( " placa " ) ,
" modelo " : payload . get ( " modelo " ) ,
" ano " : payload . get ( " ano " ) ,
" km " : payload . get ( " km " ) ,
" revisao_previa_concessionaria " : payload . get ( " revisao_previa_concessionaria " ) ,
}
sanitized = { k : v for k , v in package . items ( ) if v is not None }
required = { " placa " , " modelo " , " ano " , " km " , " revisao_previa_concessionaria " }
if not required . issubset ( sanitized . keys ( ) ) :
return
self . LAST_REVIEW_PACKAGES [ user_id ] = {
" payload " : sanitized ,
" expires_at " : datetime . utcnow ( ) + timedelta ( minutes = LAST_REVIEW_PACKAGE_TTL_MINUTES ) ,
}
def _get_last_review_package ( self , user_id : int | None ) - > dict | None :
if user_id is None :
return None
cached = self . LAST_REVIEW_PACKAGES . get ( user_id )
if not cached :
return None
if cached [ " expires_at " ] < datetime . utcnow ( ) :
self . LAST_REVIEW_PACKAGES . pop ( user_id , None )
return None
payload = cached . get ( " payload " )
return dict ( payload ) if isinstance ( payload , dict ) else None
def _is_valid_cpf ( self , cpf : str ) - > bool :
digits = re . sub ( r " \ D " , " " , cpf or " " )
if len ( digits ) != 11 :
@ -1008,13 +1391,18 @@ class OrquestradorService:
normalized_intents = self . _normalize_intents ( intents )
has_intent = normalized_intents . get ( " review_schedule " , False )
has_management_intent = normalized_intents . get ( " review_list " , False )
has_management_intent = (
normalized_intents . get ( " review_list " , False )
or normalized_intents . get ( " review_cancel " , False )
or normalized_intents . get ( " review_reschedule " , False )
)
# Nao inicia slot-filling quando a intencao atual nao e de agendamento.
if has_management_intent :
# Se o usuario mudou para gerenciamento de revisao, encerra
# qualquer coleta pendente de novo agendamento.
self . PENDING_REVIEW_DRAFTS . pop ( user_id , None )
self . PENDING_REVIEW_REUSE_CONFIRMATIONS . pop ( user_id , None )
return None
# Reaproveita rascunho anterior do usuario, se ainda estiver valido.
@ -1024,6 +1412,45 @@ class OrquestradorService:
draft = None
extracted = self . _normalize_review_fields ( extracted_fields )
pending_reuse = self . PENDING_REVIEW_REUSE_CONFIRMATIONS . get ( user_id )
if pending_reuse and pending_reuse [ " expires_at " ] < datetime . utcnow ( ) :
self . PENDING_REVIEW_REUSE_CONFIRMATIONS . pop ( user_id , None )
pending_reuse = None
if pending_reuse :
should_reuse = False
if self . _is_negative_message ( message ) :
self . PENDING_REVIEW_REUSE_CONFIRMATIONS . pop ( user_id , None )
pending_reuse = None
elif self . _is_affirmative_message ( message ) or " data_hora " in extracted :
should_reuse = True
else :
return self . _render_review_reuse_question ( )
if should_reuse :
seed_payload = dict ( pending_reuse . get ( " payload " ) or { } )
if draft is None :
draft = {
" payload " : seed_payload ,
" expires_at " : datetime . utcnow ( ) + timedelta ( minutes = PENDING_REVIEW_DRAFT_TTL_MINUTES ) ,
}
else :
for key , value in seed_payload . items ( ) :
draft [ " payload " ] . setdefault ( key , value )
self . PENDING_REVIEW_REUSE_CONFIRMATIONS . pop ( user_id , None )
pending_reuse = None
if " data_hora " not in extracted :
self . PENDING_REVIEW_DRAFTS [ user_id ] = draft
return " Perfeito. Me informe apenas a data e hora desejada para a revisao. "
if has_intent and draft is None and not extracted :
last_package = self . _get_last_review_package ( user_id = user_id )
if last_package :
self . PENDING_REVIEW_REUSE_CONFIRMATIONS [ user_id ] = {
" payload " : last_package ,
" expires_at " : datetime . utcnow ( ) + timedelta ( minutes = PENDING_REVIEW_DRAFT_TTL_MINUTES ) ,
}
return self . _render_review_reuse_question ( )
# Se houver rascunho de revisao, mas o usuario mudou para outra
# intencao operacional (ex.: compra/estoque), descarta o rascunho.
@ -1088,6 +1515,7 @@ class OrquestradorService:
# Limpa o rascunho apos tentativa final para evitar estado sujo.
self . PENDING_REVIEW_DRAFTS . pop ( user_id , None )
self . _store_last_review_package ( user_id = user_id , payload = draft [ " payload " ] )
return self . _fallback_format_tool_result ( " agendar_revisao " , tool_result )
async def _try_collect_and_create_order (
@ -1115,6 +1543,8 @@ class OrquestradorService:
and (
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 )
or normalized_intents . get ( " order_cancel " , False )
)
and not extracted
@ -1196,6 +1626,8 @@ class OrquestradorService:
and (
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 )
or normalized_intents . get ( " order_create " , False )
)
and not extracted
@ -1339,6 +1771,7 @@ class OrquestradorService:
return self . _http_exception_detail ( exc )
self . PENDING_REVIEW_CONFIRMATIONS . pop ( user_id , None )
self . _store_last_review_package ( user_id = user_id , payload = payload )
return self . _fallback_format_tool_result ( " agendar_revisao " , tool_result )
if not self . _is_affirmative_message ( message ) :
@ -1358,6 +1791,7 @@ class OrquestradorService:
return self . _http_exception_detail ( exc )
self . PENDING_REVIEW_CONFIRMATIONS . pop ( user_id , None )
self . _store_last_review_package ( user_id = user_id , payload = pending . get ( " payload " ) )
return self . _fallback_format_tool_result ( " agendar_revisao " , tool_result )
def _build_router_prompt ( self , user_message : str , user_id : int | None ) - > str :