🐛 fix(orchestration): blindar listagem e cancelamento de pedidos no fluxo estruturado

main
parent 7a7a1f0af7
commit f5a7a720ed

@ -236,6 +236,32 @@ def get_tools_definitions():
"required": ["cpf", "vehicle_id"], "required": ["cpf", "vehicle_id"],
}, },
}, },
{
"name": "listar_pedidos",
"description": (
"Use esta ferramenta quando o cliente quiser listar ou consultar os pedidos "
"que ja possui. Ela retorna os pedidos do usuario autenticado, com numero, "
"veiculo, valor e status, e tambem pode filtrar por status quando necessario."
),
"parameters": {
"type": "object",
"properties": {
"cpf": {
"type": "string",
"description": "CPF do cliente quando for necessario filtrar manualmente. Opcional.",
},
"status": {
"type": "string",
"description": "Status do pedido para filtrar, por exemplo 'Ativo' ou 'Cancelado'. Opcional.",
},
"limite": {
"type": "integer",
"description": "Quantidade maxima de pedidos retornados. Opcional.",
},
},
"required": [],
},
},
{ {
"name": "cancelar_pedido", "name": "cancelar_pedido",
"description": ( "description": (

@ -18,6 +18,17 @@ from app.services.user.user_service import UserService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _ensure_supported_runtime_configuration() -> None:
"""
Em producao, o satelite nao deve operar com estado conversacional apenas em memoria,
porque isso quebra continuidade entre reinicios e instancias.
"""
if settings.environment == "production" and settings.conversation_state_backend == "memory":
raise RuntimeError(
"Telegram satellite em producao exige conversation_state_backend diferente de memory."
)
def _acquire_single_instance_lock(lock_name: str): def _acquire_single_instance_lock(lock_name: str):
""" """
Garante apenas uma instancia local do satelite por lock de arquivo. Garante apenas uma instancia local do satelite por lock de arquivo.
@ -214,6 +225,7 @@ async def main() -> None:
token = settings.telegram_bot_token token = settings.telegram_bot_token
if not token: if not token:
raise RuntimeError("TELEGRAM_BOT_TOKEN nao configurado.") raise RuntimeError("TELEGRAM_BOT_TOKEN nao configurado.")
_ensure_supported_runtime_configuration()
lock_handle = _acquire_single_instance_lock("orquestrador_telegram_satellite.lock") lock_handle = _acquire_single_instance_lock("orquestrador_telegram_satellite.lock")

@ -2,7 +2,7 @@ from datetime import datetime
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import text from sqlalchemy import or_, text
from sqlalchemy.exc import OperationalError, SQLAlchemyError from sqlalchemy.exc import OperationalError, SQLAlchemyError
from app.db.mock_database import SessionMockLocal from app.db.mock_database import SessionMockLocal
@ -16,6 +16,79 @@ from app.services.user.mock_customer_service import hydrate_mock_customer_from_c
# Responsabilidade: regra de pedido. # Responsabilidade: regra de pedido.
async def listar_pedidos(
user_id: int | None = None,
cpf: str | None = None,
status: str | None = None,
limite: int = 20,
) -> list[dict[str, Any]]:
cpf_norm = normalize_cpf(cpf) if cpf else None
db = SessionMockLocal()
try:
user = None
if user_id is not None:
user = db.query(User).filter(User.id == user_id).first()
if user and not cpf_norm:
cpf_norm = normalize_cpf(user.cpf)
query = db.query(Order)
if user_id is not None and cpf_norm:
query = query.filter(
or_(
Order.user_id == user_id,
(Order.user_id.is_(None) & (Order.cpf == cpf_norm)),
)
)
elif user_id is not None:
query = query.filter(Order.user_id == user_id)
elif cpf_norm:
query = query.filter(Order.cpf == cpf_norm)
else:
raise_tool_http_error(
status_code=400,
code="order_list_missing_identity",
message="Preciso identificar o cliente para listar os pedidos.",
retryable=True,
field="cpf",
)
normalized_status = str(status or "").strip()
if normalized_status:
query = query.filter(Order.status == normalized_status)
safe_limit = max(1, min(int(limite or 20), 50))
pedidos = query.order_by(Order.created_at.desc()).limit(safe_limit).all()
if user_id is not None and pedidos:
attached_legacy_orders = False
for pedido in pedidos:
if pedido.user_id is None:
pedido.user_id = user_id
attached_legacy_orders = True
if attached_legacy_orders:
db.commit()
for pedido in pedidos:
db.refresh(pedido)
return [
{
"numero_pedido": pedido.numero_pedido,
"user_id": pedido.user_id,
"cpf": pedido.cpf,
"vehicle_id": pedido.vehicle_id,
"modelo_veiculo": pedido.modelo_veiculo,
"valor_veiculo": pedido.valor_veiculo,
"status": pedido.status,
"motivo": pedido.motivo_cancelamento,
"data_cancelamento": pedido.data_cancelamento.isoformat() if pedido.data_cancelamento else None,
"created_at": pedido.created_at.isoformat() if pedido.created_at else None,
}
for pedido in pedidos
]
finally:
db.close()
async def cancelar_pedido( async def cancelar_pedido(
numero_pedido: str, numero_pedido: str,
motivo: str, motivo: str,

@ -21,7 +21,32 @@ class OrderFlowMixin:
def _decision_intent(self, turn_decision: dict | None) -> str: def _decision_intent(self, turn_decision: dict | None) -> str:
return str((turn_decision or {}).get("intent") or "").strip().lower() return str((turn_decision or {}).get("intent") or "").strip().lower()
def _has_order_listing_request(self, message: str, turn_decision: dict | None = None) -> bool:
if self._decision_intent(turn_decision) == "order_list":
return True
normalized = self._normalize_text(message).strip()
listing_terms = {
"meus pedidos",
"meu pedido",
"listar pedidos",
"liste meus pedidos",
"lista de pedidos",
"quais sao meus pedidos",
"quais sao os meus pedidos",
"mostrar pedidos",
"mostre meus pedidos",
"consultar pedidos",
"ver meus pedidos",
"acompanhar pedido",
"acompanhar pedidos",
"status do pedido",
"status dos pedidos",
}
return any(term in normalized for term in listing_terms)
def _has_explicit_order_request(self, message: str) -> bool: def _has_explicit_order_request(self, message: str) -> bool:
if self._has_order_listing_request(message):
return False
normalized = self._normalize_text(message).strip() normalized = self._normalize_text(message).strip()
order_terms = { order_terms = {
"comprar", "comprar",
@ -37,6 +62,8 @@ class OrderFlowMixin:
return any(term in normalized for term in order_terms) return any(term in normalized for term in order_terms)
def _has_stock_listing_request(self, message: str, turn_decision: dict | None = None) -> bool: def _has_stock_listing_request(self, message: str, turn_decision: dict | None = None) -> bool:
if self._has_order_listing_request(message=message, turn_decision=turn_decision):
return False
if self._decision_intent(turn_decision) == "inventory_search": if self._decision_intent(turn_decision) == "inventory_search":
return True return True
normalized = self._normalize_text(message).strip() normalized = self._normalize_text(message).strip()
@ -360,6 +387,8 @@ class OrderFlowMixin:
return self._fallback_format_tool_result("consultar_estoque", tool_result) return self._fallback_format_tool_result("consultar_estoque", tool_result)
def _render_missing_cancel_order_fields_prompt(self, missing_fields: list[str]) -> str: def _render_missing_cancel_order_fields_prompt(self, missing_fields: list[str]) -> str:
if missing_fields == ["motivo"]:
return "Encontrei o pedido informado. Qual o motivo do cancelamento?"
labels = { labels = {
"numero_pedido": "o numero do pedido (ex.: PED-20260305123456-ABC123)", "numero_pedido": "o numero do pedido (ex.: PED-20260305123456-ABC123)",
"motivo": "o motivo do cancelamento", "motivo": "o motivo do cancelamento",
@ -367,6 +396,35 @@ class OrderFlowMixin:
itens = [f"- {labels[field]}" for field in missing_fields] itens = [f"- {labels[field]}" for field in missing_fields]
return "Para cancelar o pedido, preciso dos dados abaixo:\n" + "\n".join(itens) return "Para cancelar o pedido, preciso dos dados abaixo:\n" + "\n".join(itens)
async def _try_handle_order_listing(
self,
message: str,
user_id: int | None,
intents: dict | None = None,
turn_decision: dict | None = None,
) -> str | None:
if user_id is None:
return None
normalized_intents = self._normalize_intents(intents)
has_intent = (
self._decision_intent(turn_decision) == "order_list"
or normalized_intents.get("order_list", False)
or self._has_order_listing_request(message=message, turn_decision=turn_decision)
)
if not has_intent:
return None
try:
tool_result = await self.tool_executor.execute(
"listar_pedidos",
{"limite": 10},
user_id=user_id,
)
except HTTPException as exc:
return self._http_exception_detail(exc)
return self._fallback_format_tool_result("listar_pedidos", tool_result)
async def _try_collect_and_create_order( async def _try_collect_and_create_order(
self, self,
message: str, message: str,
@ -534,12 +592,14 @@ class OrderFlowMixin:
"review_list", "review_list",
"review_cancel", "review_cancel",
"review_reschedule", "review_reschedule",
"order_list",
"order_create", "order_create",
} }
or normalized_intents.get("review_schedule", False) or normalized_intents.get("review_schedule", False)
or normalized_intents.get("review_list", False) or normalized_intents.get("review_list", False)
or normalized_intents.get("review_cancel", False) or normalized_intents.get("review_cancel", False)
or normalized_intents.get("review_reschedule", False) or normalized_intents.get("review_reschedule", False)
or normalized_intents.get("order_list", False)
or normalized_intents.get("order_create", False) or normalized_intents.get("order_create", False)
) )
and not extracted and not extracted
@ -559,7 +619,7 @@ class OrderFlowMixin:
if ( if (
"motivo" not in extracted "motivo" not in extracted
and draft["payload"].get("numero_pedido") and draft["payload"].get("numero_pedido")
and not has_intent and "numero_pedido" not in extracted
): ):
# Quando o pedido ja foi identificado, um texto livre curto # Quando o pedido ja foi identificado, um texto livre curto
# e tratado como motivo do cancelamento. # e tratado como motivo do cancelamento.

@ -281,7 +281,19 @@ class ConversationPolicy:
def remove_order_selection_reset_prefix(self, message: str) -> str: def remove_order_selection_reset_prefix(self, message: str) -> str:
raw = (message or "").strip() raw = (message or "").strip()
normalized = self.service.normalizer.normalize_text(raw) normalized = self.service.normalizer.normalize_text(raw)
prefixes = ("esqueca tudo agora", "esqueca tudo", "esquece tudo agora", "esquece tudo") prefixes = (
"esqueca as operacoes anteriores e",
"esqueca as operacoes anteriores, agora",
"esqueca as operacoes anteriores agora",
"esqueca as operacoes anteriores",
"desconsidere as operacoes anteriores",
"desconsidera as operacoes anteriores",
"esqueca a conversa anterior e",
"esqueca tudo agora",
"esquece tudo agora",
"esqueca tudo",
"esquece tudo",
)
for prefix in prefixes: for prefix in prefixes:
if normalized.startswith(prefix): if normalized.startswith(prefix):
return raw[len(prefix):].lstrip(" ,.:;-") return raw[len(prefix):].lstrip(" ,.:;-")
@ -292,6 +304,12 @@ class ConversationPolicy:
def is_order_selection_reset_message(self, message: str) -> bool: def is_order_selection_reset_message(self, message: str) -> bool:
normalized = self.service.normalizer.normalize_text(message).strip() normalized = self.service.normalizer.normalize_text(message).strip()
reset_terms = { reset_terms = {
"esqueca as operacoes anteriores e",
"esqueca as operacoes anteriores, agora",
"esqueca as operacoes anteriores",
"desconsidere as operacoes anteriores",
"desconsidera as operacoes anteriores",
"esqueca a conversa anterior",
"esqueca tudo", "esqueca tudo",
"esqueca tudo agora", "esqueca tudo agora",
"esquece tudo", "esquece tudo",
@ -596,7 +614,11 @@ class ConversationPolicy:
+ int(normalized.get("review_cancel", False)) + int(normalized.get("review_cancel", False))
+ int(normalized.get("review_reschedule", False)) + int(normalized.get("review_reschedule", False))
) )
sales_score = int(normalized.get("order_create", False)) + int(normalized.get("order_cancel", False)) sales_score = (
int(normalized.get("order_create", False))
+ int(normalized.get("order_list", False))
+ int(normalized.get("order_cancel", False))
)
if review_score > sales_score and review_score > 0: if review_score > sales_score and review_score > 0:
return "review" return "review"
if sales_score > review_score and sales_score > 0: if sales_score > review_score and sales_score > 0:
@ -718,6 +740,12 @@ class ConversationPolicy:
if pending_switch["expires_at"] < datetime.utcnow(): if pending_switch["expires_at"] < datetime.utcnow():
context["pending_switch"] = None context["pending_switch"] = None
self._save_context(user_id=user_id, context=context) self._save_context(user_id=user_id, context=context)
elif (
self._decision_domain(turn_decision) in {"review", "sales"}
and self._decision_domain(turn_decision) != pending_switch["target_domain"]
):
context["pending_switch"] = None
self._save_context(user_id=user_id, context=context)
elif self.is_context_switch_confirmation(message, turn_decision=turn_decision): elif self.is_context_switch_confirmation(message, turn_decision=turn_decision):
if self.service._is_affirmative_message(message) or self._decision_domain(turn_decision) == pending_switch["target_domain"]: if self.service._is_affirmative_message(message) or self._decision_domain(turn_decision) == pending_switch["target_domain"]:
target_domain = pending_switch["target_domain"] target_domain = pending_switch["target_domain"]

@ -21,6 +21,8 @@ class EntityNormalizer:
"buy_car": "order_create", "buy_car": "order_create",
"purchase_car": "order_create", "purchase_car": "order_create",
"cancel_order": "order_cancel", "cancel_order": "order_cancel",
"list_orders": "order_list",
"show_orders": "order_list",
"list_inventory": "inventory_search", "list_inventory": "inventory_search",
"search_inventory": "inventory_search", "search_inventory": "inventory_search",
"clear_conversation": "conversation_reset", "clear_conversation": "conversation_reset",
@ -38,6 +40,44 @@ class EntityNormalizer:
"veiculo": "vehicle_id", "veiculo": "vehicle_id",
"carro": "vehicle_id", "carro": "vehicle_id",
} }
_TOOL_ARGUMENT_ALIASES = {
"cancelar_pedido": {
"order_id": "numero_pedido",
"pedido_id": "numero_pedido",
"id_pedido": "numero_pedido",
"numero": "numero_pedido",
"order_number": "numero_pedido",
"reason": "motivo",
},
"realizar_pedido": {
"order_id": "vehicle_id",
"car_id": "vehicle_id",
"id_veiculo": "vehicle_id",
"customer_cpf": "cpf",
},
"listar_pedidos": {
"max_results": "limite",
"limit": "limite",
"customer_cpf": "cpf",
},
"cancelar_agendamento_revisao": {
"review_id": "protocolo",
"schedule_id": "protocolo",
"reason": "motivo",
},
"editar_data_revisao": {
"review_id": "protocolo",
"schedule_id": "protocolo",
"data_hora": "nova_data_hora",
"new_datetime": "nova_data_hora",
},
}
_TOOL_REQUIRED_ARGUMENTS = {
"cancelar_pedido": ("numero_pedido", "motivo"),
"realizar_pedido": ("cpf", "vehicle_id"),
"editar_data_revisao": ("protocolo", "nova_data_hora"),
"cancelar_agendamento_revisao": ("protocolo",),
}
def empty_turn_decision(self) -> dict: def empty_turn_decision(self) -> dict:
return TurnDecision().model_dump() return TurnDecision().model_dump()
@ -167,11 +207,18 @@ class EntityNormalizer:
if isinstance(entities, dict): if isinstance(entities, dict):
normalized["entities"] = dict(entities) normalized["entities"] = dict(entities)
tool_name = str(normalized.get("tool_name") or "").strip()
tool_arguments = normalized.get("tool_arguments")
if tool_name and isinstance(tool_arguments, dict):
normalized["tool_arguments"] = self.normalize_tool_arguments(tool_name, tool_arguments)
if self._should_route_order_alias_to_collection(normalized): if self._should_route_order_alias_to_collection(normalized):
normalized["action"] = "collect_order_create" normalized["action"] = "collect_order_create"
normalized["missing_fields"] = [] normalized["missing_fields"] = []
normalized["response_to_user"] = None normalized["response_to_user"] = None
normalized = self._coerce_incomplete_tool_call_to_collection(normalized)
return normalized return normalized
def _normalize_turn_missing_fields(self, missing_fields: list) -> list[str]: def _normalize_turn_missing_fields(self, missing_fields: list) -> list[str]:
@ -203,9 +250,123 @@ class EntityNormalizer:
return False return False
return True return True
def _coerce_incomplete_tool_call_to_collection(self, payload: dict) -> dict:
if payload.get("action") != "call_tool":
return payload
tool_name = str(payload.get("tool_name") or "").strip()
if not tool_name:
return payload
required_fields = self._TOOL_REQUIRED_ARGUMENTS.get(tool_name)
tool_arguments = payload.get("tool_arguments")
if not required_fields or not isinstance(tool_arguments, dict):
return payload
missing_fields = [field for field in required_fields if tool_arguments.get(field) in (None, "", [])]
if not missing_fields:
return payload
entities = payload.get("entities")
if not isinstance(entities, dict):
entities = self.empty_extraction_payload()
payload["entities"] = entities
if tool_name == "cancelar_pedido" and payload.get("intent") == "order_cancel":
cancel_entities = self.normalize_cancel_order_fields(
{
**(entities.get("cancel_order_fields") or {}),
**tool_arguments,
}
)
entities["cancel_order_fields"] = cancel_entities
payload["action"] = "collect_order_cancel"
payload["tool_name"] = None
payload["tool_arguments"] = {}
payload["missing_fields"] = []
payload["response_to_user"] = None
return payload
if tool_name == "realizar_pedido" and payload.get("intent") == "order_create":
order_entities = self.normalize_order_fields(
{
**(entities.get("order_fields") or {}),
**tool_arguments,
}
)
entities["order_fields"] = order_entities
payload["action"] = "collect_order_create"
payload["tool_name"] = None
payload["tool_arguments"] = {}
payload["missing_fields"] = []
payload["response_to_user"] = None
return payload
if tool_name in {"cancelar_agendamento_revisao", "editar_data_revisao"} and str(payload.get("domain") or "") == "review":
review_management_entities = self.normalize_review_management_fields(
{
**(entities.get("review_management_fields") or {}),
**tool_arguments,
}
)
entities["review_management_fields"] = review_management_entities
payload["action"] = "collect_review_management"
payload["tool_name"] = None
payload["tool_arguments"] = {}
payload["missing_fields"] = []
payload["response_to_user"] = None
return payload
return payload
def normalize_text(self, text: str) -> str: def normalize_text(self, text: str) -> str:
return technical_normalizer.normalize_text(text) return technical_normalizer.normalize_text(text)
def normalize_tool_arguments(self, tool_name: str, arguments) -> dict:
if not isinstance(arguments, dict):
return {}
normalized_tool_name = str(tool_name or "").strip()
aliases = self._TOOL_ARGUMENT_ALIASES.get(normalized_tool_name, {})
normalized_arguments: dict = {}
for raw_key, value in arguments.items():
candidate_key = self.normalize_text(str(raw_key or "")).replace("-", "_").replace(" ", "_")
canonical_key = aliases.get(candidate_key, candidate_key)
if canonical_key not in normalized_arguments:
normalized_arguments[canonical_key] = value
if normalized_tool_name == "cancelar_pedido":
coerced = self.normalize_cancel_order_fields(normalized_arguments)
if "motivo" not in coerced and isinstance(normalized_arguments.get("motivo"), str):
motivo = str(normalized_arguments.get("motivo") or "").strip()
if motivo:
coerced["motivo"] = motivo
return coerced
if normalized_tool_name == "realizar_pedido":
return self.normalize_order_fields(normalized_arguments)
if normalized_tool_name == "listar_pedidos":
coerced: dict = {}
cpf = self.normalize_cpf(normalized_arguments.get("cpf"))
if cpf:
coerced["cpf"] = cpf
status = str(normalized_arguments.get("status") or "").strip()
if status:
coerced["status"] = status
limite = self.normalize_positive_number(normalized_arguments.get("limite"))
if limite:
coerced["limite"] = max(1, min(int(round(limite)), 50))
return coerced
if normalized_tool_name == "cancelar_agendamento_revisao":
return self.normalize_review_management_fields(normalized_arguments)
if normalized_tool_name == "editar_data_revisao":
return self.normalize_review_management_fields(normalized_arguments)
return normalized_arguments
def normalize_plate(self, value) -> str | None: def normalize_plate(self, value) -> str | None:
return technical_normalizer.normalize_plate(value) return technical_normalizer.normalize_plate(value)
@ -353,6 +514,7 @@ class EntityNormalizer:
"review_cancel": bool(self.normalize_bool(data.get("review_cancel"))), "review_cancel": bool(self.normalize_bool(data.get("review_cancel"))),
"review_reschedule": bool(self.normalize_bool(data.get("review_reschedule"))), "review_reschedule": bool(self.normalize_bool(data.get("review_reschedule"))),
"order_create": bool(self.normalize_bool(data.get("order_create"))), "order_create": bool(self.normalize_bool(data.get("order_create"))),
"order_list": bool(self.normalize_bool(data.get("order_list"))),
"order_cancel": bool(self.normalize_bool(data.get("order_cancel"))), "order_cancel": bool(self.normalize_bool(data.get("order_cancel"))),
} }

@ -32,7 +32,7 @@ class MessagePlanner:
' "review_management_fields": {"protocolo": null, "nova_data_hora": null, "motivo": null},\n' ' "review_management_fields": {"protocolo": null, "nova_data_hora": null, "motivo": null},\n'
' "order_fields": {"cpf": null, "vehicle_id": null, "modelo_veiculo": null},\n' ' "order_fields": {"cpf": null, "vehicle_id": null, "modelo_veiculo": null},\n'
' "cancel_order_fields": {"numero_pedido": null, "motivo": 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' ' "intents": {"review_schedule": false, "review_list": false, "review_cancel": false, "review_reschedule": false, "order_create": false, "order_list": false, "order_cancel": false}\n'
" }\n" " }\n"
" }\n" " }\n"
" ]\n" " ]\n"
@ -113,6 +113,7 @@ class MessagePlanner:
' "review_cancel": false,\n' ' "review_cancel": false,\n'
' "review_reschedule": false,\n' ' "review_reschedule": false,\n'
' "order_create": false,\n' ' "order_create": false,\n'
' "order_list": false,\n'
' "order_cancel": false\n' ' "order_cancel": false\n'
" }\n" " }\n"
"}\n\n" "}\n\n"
@ -190,6 +191,7 @@ class MessagePlanner:
"- 'entities' deve manter as secoes generic_memory, review_fields, review_management_fields, order_fields e cancel_order_fields.\n" "- 'entities' deve manter as secoes generic_memory, review_fields, review_management_fields, order_fields e cancel_order_fields.\n"
"- Em pedidos de compra com faixa de preco ou orcamento (ex.: '70 mil', 'ate 50 mil', 'R$ 45000'), preencha entities.generic_memory.orcamento_max.\n" "- Em pedidos de compra com faixa de preco ou orcamento (ex.: '70 mil', 'ate 50 mil', 'R$ 45000'), preencha entities.generic_memory.orcamento_max.\n"
"- Em pedidos com tipo de carro (ex.: suv, sedan, hatch, pickup), preencha entities.generic_memory.perfil_veiculo.\n" "- Em pedidos com tipo de carro (ex.: suv, sedan, hatch, pickup), preencha entities.generic_memory.perfil_veiculo.\n"
"- Se o usuario quiser listar os pedidos dele, use intent='order_list', domain='sales', action='call_tool' e tool_name='listar_pedidos'.\n"
"- Se faltar dado para continuar um fluxo, use action='ask_missing_fields' e preencha 'missing_fields' e 'response_to_user'.\n" "- Se faltar dado para continuar um fluxo, use action='ask_missing_fields' e preencha 'missing_fields' e 'response_to_user'.\n"
"- Se o usuario estiver escolhendo entre pedidos enfileirados (ex.: '1', '2', 'o segundo'), preencha 'selection_index' com base zero.\n" "- Se o usuario estiver escolhendo entre pedidos enfileirados (ex.: '1', '2', 'o segundo'), preencha 'selection_index' com base zero.\n"
"- Se for necessaria uma tool de orquestracao, use action compativel e preencha 'tool_name' e 'tool_arguments' quando apropriado.\n" "- Se for necessaria uma tool de orquestracao, use action compativel e preencha 'tool_name' e 'tool_arguments' quando apropriado.\n"

@ -40,6 +40,8 @@ LOW_VALUE_RESPONSES = {
} }
DETERMINISTIC_RESPONSE_TOOLS = { DETERMINISTIC_RESPONSE_TOOLS = {
"cancelar_pedido",
"listar_pedidos",
"limpar_contexto_conversa", "limpar_contexto_conversa",
"continuar_proximo_pedido", "continuar_proximo_pedido",
"descartar_pedidos_pendentes", "descartar_pedidos_pendentes",

@ -85,6 +85,14 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
message=message, message=message,
user_id=user_id, user_id=user_id,
) )
reset_override = await self._try_handle_immediate_context_reset(
message=message,
user_id=user_id,
turn_decision=early_turn_decision,
finish=finish,
)
if reset_override:
return reset_override
pending_order_selection = await self._try_resolve_pending_order_selection( pending_order_selection = await self._try_resolve_pending_order_selection(
message=message, message=message,
user_id=user_id, user_id=user_id,
@ -132,10 +140,13 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
) )
# Depois do roteamento para um unico pedido, pede a decisao # Depois do roteamento para um unico pedido, pede a decisao
# estruturada do turno final que sera executado. # estruturada do turno final que sera executado.
turn_decision = await self._extract_turn_decision_with_llm( if (routing_message or "").strip() == (message or "").strip():
message=routing_message, turn_decision = early_turn_decision
user_id=user_id, else:
) turn_decision = await self._extract_turn_decision_with_llm(
message=routing_message,
user_id=user_id,
)
llm_extracted_entities = await self._extract_entities_with_llm( llm_extracted_entities = await self._extract_entities_with_llm(
message=routing_message, message=routing_message,
user_id=user_id, user_id=user_id,
@ -203,13 +214,29 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
decision_action = str(turn_decision.get("action") or "") decision_action = str(turn_decision.get("action") or "")
decision_response = str(turn_decision.get("response_to_user") or "").strip() decision_response = str(turn_decision.get("response_to_user") or "").strip()
should_prioritize_review_flow = self._should_prioritize_review_flow(
turn_decision=turn_decision,
extracted_entities=extracted_entities,
user_id=user_id,
)
should_prioritize_order_flow = self._should_prioritize_order_flow( should_prioritize_order_flow = self._should_prioritize_order_flow(
turn_decision=turn_decision, turn_decision=turn_decision,
extracted_entities=extracted_entities, extracted_entities=extracted_entities,
user_id=user_id,
) )
if decision_action == "ask_missing_fields" and decision_response and not should_prioritize_order_flow: if (
decision_action == "ask_missing_fields"
and decision_response
and not should_prioritize_review_flow
and not should_prioritize_order_flow
):
return await finish(decision_response, queue_notice=queue_notice) return await finish(decision_response, queue_notice=queue_notice)
if decision_action == "answer_user" and decision_response and not should_prioritize_order_flow: if (
decision_action == "answer_user"
and decision_response
and not should_prioritize_review_flow
and not should_prioritize_order_flow
):
return await finish(decision_response, queue_notice=queue_notice) return await finish(decision_response, queue_notice=queue_notice)
planned_tool_response = await self._try_execute_business_tool_from_turn_decision( planned_tool_response = await self._try_execute_business_tool_from_turn_decision(
@ -262,6 +289,14 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
) )
if cancel_order_response: if cancel_order_response:
return await finish(cancel_order_response, queue_notice=queue_notice) return await finish(cancel_order_response, queue_notice=queue_notice)
order_listing_response = await self._try_handle_order_listing(
message=routing_message,
user_id=user_id,
intents={},
turn_decision=turn_decision,
)
if order_listing_response:
return await finish(order_listing_response, queue_notice=queue_notice)
# 4) Fluxo de coleta incremental para realizacao de pedido. # 4) Fluxo de coleta incremental para realizacao de pedido.
order_response = await self._try_collect_and_create_order( order_response = await self._try_collect_and_create_order(
message=routing_message, message=routing_message,
@ -857,7 +892,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
decision = turn_decision or {} decision = turn_decision or {}
decision_intent = str(decision.get("intent") or "").strip().lower() decision_intent = str(decision.get("intent") or "").strip().lower()
decision_domain = str(decision.get("domain") or "").strip().lower() decision_domain = str(decision.get("domain") or "").strip().lower()
if decision_domain != "sales" and decision_intent not in {"order_create", "inventory_search"}: if decision_domain != "sales" and decision_intent not in {"order_create", "order_list", "inventory_search"}:
return {} return {}
generic_memory = (extracted_entities or {}).get("generic_memory") generic_memory = (extracted_entities or {}).get("generic_memory")
@ -870,6 +905,36 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
async def _extract_turn_decision_with_llm(self, message: str, user_id: int | None) -> dict: async def _extract_turn_decision_with_llm(self, message: str, user_id: int | None) -> dict:
return await self.planner.extract_turn_decision(message=message, user_id=user_id) return await self.planner.extract_turn_decision(message=message, user_id=user_id)
async def _try_handle_immediate_context_reset(
self,
message: str,
user_id: int | None,
turn_decision: dict | None,
finish,
) -> str | None:
decision = turn_decision or {}
decision_action = str(decision.get("action") or "").strip().lower()
decision_intent = str(decision.get("intent") or "").strip().lower()
if (
decision_action != "clear_context"
and decision_intent != "conversation_reset"
and not (
hasattr(self, "policy")
and self._is_order_selection_reset_message(message)
)
):
return None
cleaned_message = (
self._remove_order_selection_reset_prefix(message)
if hasattr(self, "policy")
else message
)
self._clear_user_conversation_state(user_id=user_id)
if not cleaned_message or cleaned_message.strip() == (message or "").strip():
return await finish("Contexto da conversa limpo. Podemos recomecar do zero.")
return await self.handle_message(cleaned_message, user_id=user_id)
def _resolve_entities_for_message_plan(self, message_plan: dict, routed_message: str) -> dict: def _resolve_entities_for_message_plan(self, message_plan: dict, routed_message: str) -> dict:
return self.planner.resolve_entities_for_message_plan(message_plan=message_plan, routed_message=routed_message) return self.planner.resolve_entities_for_message_plan(message_plan=message_plan, routed_message=routed_message)
@ -929,9 +994,27 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
self, self,
turn_decision: dict | None, turn_decision: dict | None,
extracted_entities: dict | None, extracted_entities: dict | None,
user_id: int | None = None,
) -> bool: ) -> bool:
decision = turn_decision or {} decision = turn_decision or {}
if str(decision.get("intent") or "").strip().lower() != "order_create": decision_intent = str(decision.get("intent") or "").strip().lower()
has_open_cancel_order_draft = bool(
user_id is not None and self.state.get_entry("pending_cancel_order_drafts", user_id, expire=True)
)
if has_open_cancel_order_draft:
return True
if decision_intent == "order_list":
return True
if decision_intent == "order_cancel":
cancel_order_fields = (extracted_entities if isinstance(extracted_entities, dict) else {}).get("cancel_order_fields")
if not isinstance(cancel_order_fields, dict):
cancel_order_fields = {}
return bool(
cancel_order_fields.get("numero_pedido")
or cancel_order_fields.get("motivo")
or has_open_cancel_order_draft
)
if decision_intent != "order_create":
return False return False
entities = extracted_entities if isinstance(extracted_entities, dict) else {} entities = extracted_entities if isinstance(extracted_entities, dict) else {}
@ -952,6 +1035,48 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
) )
) )
def _should_prioritize_review_flow(
self,
turn_decision: dict | None,
extracted_entities: dict | None,
user_id: int | None = None,
) -> bool:
has_open_review_draft = bool(
user_id is not None
and (
self.state.get_entry("pending_review_drafts", user_id, expire=True)
or self.state.get_entry("pending_review_reuse_confirmations", user_id, expire=True)
or self.state.get_entry("pending_review_confirmations", user_id, expire=True)
)
)
if has_open_review_draft:
return True
decision = turn_decision or {}
decision_intent = str(decision.get("intent") or "").strip().lower()
if decision_intent != "review_schedule":
return False
entities = extracted_entities if isinstance(extracted_entities, dict) else {}
review_fields = entities.get("review_fields")
generic_memory = entities.get("generic_memory")
if not isinstance(review_fields, dict):
review_fields = {}
if not isinstance(generic_memory, dict):
generic_memory = {}
return any(
(
review_fields.get("placa"),
review_fields.get("data_hora"),
review_fields.get("modelo"),
review_fields.get("ano"),
review_fields.get("km"),
review_fields.get("revisao_previa_concessionaria"),
generic_memory.get("placa"),
)
)
def _parse_json_object(self, text: str): def _parse_json_object(self, text: str):
return self.normalizer.parse_json_object(text) return self.normalizer.parse_json_object(text)

@ -62,6 +62,21 @@ def fallback_format_tool_result(tool_name: str, tool_result: Any) -> str:
lines.append(f"Status do veiculo: {status_veiculo}") lines.append(f"Status do veiculo: {status_veiculo}")
return "\n".join(lines) return "\n".join(lines)
if tool_name == "listar_pedidos" and isinstance(tool_result, list):
if not tool_result:
return "Nao encontrei pedidos vinculados a sua conta."
linhas = [f"Encontrei {len(tool_result)} pedido(s):"]
for idx, item in enumerate(tool_result[:10], start=1):
numero = item.get("numero_pedido", "N/A")
modelo = item.get("modelo_veiculo") or "Veiculo nao informado"
status = item.get("status", "N/A")
valor = format_currency_br(item.get("valor_veiculo")) if item.get("valor_veiculo") is not None else "N/A"
linhas.append(f"{idx}. {numero} | {modelo} | {status} | {valor}")
restantes = len(tool_result) - 10
if restantes > 0:
linhas.append(f"... e mais {restantes} pedido(s).")
return "\n".join(linhas)
if tool_name == "agendar_revisao" and isinstance(tool_result, dict): if tool_name == "agendar_revisao" and isinstance(tool_result, dict):
placa = tool_result.get("placa", "N/A") placa = tool_result.get("placa", "N/A")
data_hora = format_datetime_for_chat(tool_result.get("data_hora", "N/A")) data_hora = format_datetime_for_chat(tool_result.get("data_hora", "N/A"))

@ -11,6 +11,7 @@ TurnIntent = Literal[
"review_cancel", "review_cancel",
"review_reschedule", "review_reschedule",
"order_create", "order_create",
"order_list",
"order_cancel", "order_cancel",
"inventory_search", "inventory_search",
"conversation_reset", "conversation_reset",

@ -2,7 +2,7 @@ from typing import Any
from app.services.domain.credit_service import validar_cliente_venda from app.services.domain.credit_service import validar_cliente_venda
from app.services.domain.inventory_service import avaliar_veiculo_troca, consultar_estoque from app.services.domain.inventory_service import avaliar_veiculo_troca, consultar_estoque
from app.services.domain.order_service import cancelar_pedido, realizar_pedido from app.services.domain.order_service import cancelar_pedido, listar_pedidos, realizar_pedido
from app.services.domain.review_service import ( from app.services.domain.review_service import (
agendar_revisao, agendar_revisao,
cancelar_agendamento_revisao, cancelar_agendamento_revisao,
@ -28,6 +28,7 @@ __all__ = [
"consultar_estoque", "consultar_estoque",
"editar_data_revisao", "editar_data_revisao",
"listar_agendamentos_revisao", "listar_agendamentos_revisao",
"listar_pedidos",
"realizar_pedido", "realizar_pedido",
"validar_cliente_venda", "validar_cliente_venda",
] ]

@ -12,6 +12,7 @@ from app.services.tools.handlers import (
cancelar_pedido, cancelar_pedido,
editar_data_revisao, editar_data_revisao,
listar_agendamentos_revisao, listar_agendamentos_revisao,
listar_pedidos,
consultar_estoque, consultar_estoque,
realizar_pedido, realizar_pedido,
validar_cliente_venda, validar_cliente_venda,
@ -27,6 +28,7 @@ HANDLERS: Dict[str, Callable] = {
"cancelar_agendamento_revisao": cancelar_agendamento_revisao, "cancelar_agendamento_revisao": cancelar_agendamento_revisao,
"editar_data_revisao": editar_data_revisao, "editar_data_revisao": editar_data_revisao,
"cancelar_pedido": cancelar_pedido, "cancelar_pedido": cancelar_pedido,
"listar_pedidos": listar_pedidos,
"realizar_pedido": realizar_pedido, "realizar_pedido": realizar_pedido,
} }

Loading…
Cancel
Save