🐛 fix(orchestration): blindar compra, revisao e respostas longas no Telegram

- prioriza cancelamento e listagem corretos sobre drafts antigos de compra, limpa o contexto apos reprovacao definitiva de credito, orienta CPF invalido sem reiniciar o fluxo e reduz listagens de estoque para opcoes curtas e consistentes com a selecao exibida

- adiciona um caminho dedicado para avaliacao de veiculo na troca com aliases normalizados, formatter deterministico e protecao contra desvio indevido para fluxos de revisao ou respostas livres do modelo

- estabiliza cancelamento, remarcacao e listagem de revisoes, reaproveita motivo informado na mesma mensagem, mantem listagens completas em formato compacto e fragmenta automaticamente respostas longas antes do envio ao Telegram

- amplia a cobertura de regressao para compra, revisao, fila conversacional, avaliacao de troca e particionamento de mensagens no satellite
main
parent c7175aa700
commit d1bd972f57

@ -17,6 +17,59 @@ from app.services.user.user_service import UserService
logger = logging.getLogger(__name__)
TELEGRAM_MESSAGE_SAFE_LIMIT = 3800
def _split_telegram_text(text: str, limit: int = TELEGRAM_MESSAGE_SAFE_LIMIT) -> List[str]:
normalized = str(text or "").strip()
if not normalized:
return [""]
if len(normalized) <= limit:
return [normalized]
chunks: List[str] = []
current = ""
def flush_current() -> None:
nonlocal current
if current:
chunks.append(current)
current = ""
paragraphs = normalized.split("\n\n")
for paragraph in paragraphs:
candidate = paragraph if not current else f"{current}\n\n{paragraph}"
if len(candidate) <= limit:
current = candidate
continue
flush_current()
if len(paragraph) <= limit:
current = paragraph
continue
line_buffer = ""
for line in paragraph.split("\n"):
line_candidate = line if not line_buffer else f"{line_buffer}\n{line}"
if len(line_candidate) <= limit:
line_buffer = line_candidate
continue
if line_buffer:
chunks.append(line_buffer)
line_buffer = ""
while len(line) > limit:
chunks.append(line[:limit])
line = line[limit:]
line_buffer = line
if line_buffer:
current = line_buffer
flush_current()
return chunks or [normalized[:limit]]
def _ensure_supported_runtime_configuration() -> None:
"""
@ -185,9 +238,10 @@ class TelegramSatelliteService:
text: str,
) -> None:
"""Envia mensagem de texto para o chat informado no Telegram."""
for chunk in _split_telegram_text(text):
payload = {
"chat_id": chat_id,
"text": text,
"text": chunk,
}
async with session.post(f"{self.base_url}/sendMessage", json=payload) as response:
data = await response.json()

@ -214,6 +214,18 @@ class OrderFlowMixin:
def _is_valid_cpf(self, cpf: str) -> bool:
return is_valid_cpf(cpf)
def _extract_order_cpf_attempt(self, message: str) -> str | None:
normalized = self._normalize_text(message).strip()
digits = re.sub(r"\D", "", str(message or ""))
if len(digits) < 3 or len(digits) > 11:
return None
if "cpf" in normalized:
return digits
residue = re.sub(r"[\d\s\.\-_/]", "", str(message or ""))
if not residue:
return digits
return None
def _try_capture_order_cpf_from_message(self, message: str, payload: dict) -> None:
if payload.get("cpf"):
return
@ -714,6 +726,11 @@ class OrderFlowMixin:
return any(term in normalized for term in restart_terms)
def _render_missing_order_fields_prompt(self, missing_fields: list[str]) -> str:
if missing_fields == ["vehicle_id"]:
return (
"Para seguir com o pedido, me diga qual carro voce procura.\n"
"Se preferir, posso listar opcoes por faixa de preco, modelo ou tipo de carro."
)
labels = {
"cpf": "o CPF do cliente",
"vehicle_id": "qual veiculo do estoque voce quer comprar",
@ -816,7 +833,7 @@ class OrderFlowMixin:
try:
tool_result = await self.tool_executor.execute(
"listar_pedidos",
{"limite": 10},
{"limite": 50},
user_id=user_id,
)
except HTTPException as exc:
@ -898,6 +915,17 @@ class OrderFlowMixin:
draft["payload"].update(extracted)
self._try_capture_order_cpf_from_message(message=message, payload=draft["payload"])
cpf_attempt = self._extract_order_cpf_attempt(message)
if cpf_attempt and not draft["payload"].get("cpf") and not self._is_valid_cpf(cpf_attempt):
draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_ORDER_DRAFT_TTL_MINUTES)
self._set_order_flow_entry(
"pending_order_drafts",
user_id,
"order_create",
draft,
active_task="order_create",
)
return "Para seguir com o pedido, preciso de um CPF valido. Pode me informar novamente?"
self._try_capture_order_budget_from_message(user_id=user_id, message=message, payload=draft["payload"])
self._try_prefill_order_cpf_from_memory(user_id=user_id, payload=draft["payload"])
self._try_prefill_order_vehicle_from_context(user_id=user_id, payload=draft["payload"])
@ -1022,6 +1050,14 @@ class OrderFlowMixin:
draft,
active_task="order_create",
)
else:
self._pop_order_flow_entry(
"pending_order_drafts",
user_id,
"order_create",
active_task="order_create",
)
self._reset_order_stock_context(user_id=user_id)
return self._http_exception_detail(exc)
self._pop_order_flow_entry(
"pending_order_drafts",

@ -366,6 +366,26 @@ class ReviewFlowMixin:
return "cancel"
return None
def _extract_review_cancel_reason_from_message(self, message: str) -> str | None:
raw_message = str(message or "").strip()
if len(raw_message) < 4:
return None
patterns = (
r"\bporque\b",
r"\bpois\b",
r"\bpor conta de\b",
r"\bmotivo(?: do cancelamento)?\b\s*[:\-]?",
r"\bja que\b",
)
for pattern in patterns:
match = re.search(pattern, raw_message, flags=re.IGNORECASE)
if not match:
continue
reason = raw_message[match.end():].strip(" ,.;:-")
if len(reason) >= 4:
return reason
return None
def _should_bootstrap_review_from_active_context(self, message: str, payload: dict | None = None) -> bool:
normalized_message = self._normalize_text(message).strip()
normalized_payload = payload if isinstance(payload, dict) else {}
@ -455,7 +475,7 @@ class ReviewFlowMixin:
try:
tool_result = await self.tool_executor.execute(
"listar_agendamentos_revisao",
{"limite": 20},
{"limite": 100},
user_id=user_id,
)
except HTTPException as exc:
@ -485,16 +505,16 @@ class ReviewFlowMixin:
extracted["protocolo"] = inferred_protocol
action = draft.get("action", "cancel")
current_protocol = extracted.get("protocolo") or draft["payload"].get("protocolo")
if action == "reschedule" and "nova_data_hora" not in extracted:
normalized_new_datetime = self._extract_review_management_datetime_from_message(message)
if normalized_new_datetime:
extracted["nova_data_hora"] = normalized_new_datetime
if (
action == "cancel"
and "motivo" not in extracted
and draft["payload"].get("protocolo")
and not has_cancel_intent
):
if action == "cancel" and "motivo" not in extracted and current_protocol:
inferred_reason = self._extract_review_cancel_reason_from_message(message)
if inferred_reason:
extracted["motivo"] = inferred_reason
elif 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

@ -35,6 +35,9 @@ class EntityNormalizer:
"cancelar_agendamento": "cancelar_agendamento_revisao",
"reschedule_review": "editar_data_revisao",
"remarcar_revisao": "editar_data_revisao",
"avaliar_troca_veiculo": "avaliar_veiculo_troca",
"avaliar_troca": "avaliar_veiculo_troca",
"trade_in_evaluation": "avaliar_veiculo_troca",
}
_TURN_INTENT_ALIASES = {
"create_order": "order_create",
@ -156,6 +159,14 @@ class EntityNormalizer:
"new_datetime": "nova_data_hora",
}
_TOOL_ARGUMENT_ALIASES = {
"consultar_estoque": {
"max_results": "limite",
"limit": "limite",
"price_cap": "preco_max",
"max_price": "preco_max",
"vehicle_category": "categoria",
"price_order": "ordenar_preco",
},
"cancelar_pedido": {
"order_id": "numero_pedido",
"pedido_id": "numero_pedido",
@ -170,6 +181,12 @@ class EntityNormalizer:
"id_veiculo": "vehicle_id",
"customer_cpf": "cpf",
},
"avaliar_veiculo_troca": {
"vehicle_model": "modelo",
"vehicle_year": "ano",
"quilometragem": "km",
"vehicle_km": "km",
},
"listar_pedidos": {
"max_results": "limite",
"limit": "limite",
@ -849,6 +866,21 @@ class EntityNormalizer:
if canonical_key not in normalized_arguments:
normalized_arguments[canonical_key] = value
if normalized_tool_name == "consultar_estoque":
coerced: dict = {}
preco_max = self.normalize_positive_number(normalized_arguments.get("preco_max"))
if preco_max:
coerced["preco_max"] = float(preco_max)
categoria = str(normalized_arguments.get("categoria") or "").strip().lower()
if categoria:
coerced["categoria"] = categoria
ordenar_preco = str(normalized_arguments.get("ordenar_preco") or "").strip().lower()
if ordenar_preco in {"asc", "desc"}:
coerced["ordenar_preco"] = ordenar_preco
limite = self.normalize_positive_number(normalized_arguments.get("limite"))
coerced["limite"] = max(1, min(int(round(limite)) if limite else 5, 5))
return coerced
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):
@ -860,6 +892,19 @@ class EntityNormalizer:
if normalized_tool_name == "realizar_pedido":
return self.normalize_order_fields(normalized_arguments)
if normalized_tool_name == "avaliar_veiculo_troca":
coerced: dict = {}
modelo = str(normalized_arguments.get("modelo") or "").strip()
if modelo:
coerced["modelo"] = modelo
ano = self.normalize_positive_number(normalized_arguments.get("ano"))
if ano:
coerced["ano"] = int(round(ano))
km = self.normalize_positive_number(normalized_arguments.get("km"))
if km is not None:
coerced["km"] = int(round(km))
return coerced
if normalized_tool_name == "listar_pedidos":
coerced: dict = {}
cpf = self.normalize_cpf(normalized_arguments.get("cpf"))
@ -869,8 +914,7 @@ class EntityNormalizer:
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))
coerced["limite"] = max(1, min(int(round(limite)) if limite else 50, 50))
return coerced
if normalized_tool_name == "listar_agendamentos_revisao":
@ -882,8 +926,7 @@ class EntityNormalizer:
if status:
coerced["status"] = status
limite = self.normalize_positive_number(normalized_arguments.get("limite"))
if limite:
coerced["limite"] = max(1, min(int(round(limite)), 100))
coerced["limite"] = max(1, min(int(round(limite)) if limite else 100, 100))
return coerced
if normalized_tool_name == "cancelar_agendamento_revisao":

@ -196,6 +196,7 @@ class MessagePlanner:
"- Se o usuario quiser listar agendamentos de revisao, use intent='review_list', domain='review', action='call_tool' e tool_name='listar_agendamentos_revisao'.\n"
"- Se o usuario quiser cancelar um agendamento de revisao, use intent='review_cancel', domain='review' e prefira tool_name='cancelar_agendamento_revisao'.\n"
"- Se o usuario quiser remarcar um agendamento de revisao, use intent='review_reschedule', domain='review' e prefira tool_name='editar_data_revisao'.\n"
"- Se o usuario quiser avaliar um veiculo na troca e houver modelo, ano e km, use domain='sales', action='call_tool', tool_name='avaliar_veiculo_troca' e informe esses campos em 'tool_arguments'. Nao peca versao ou placa se isso nao foi solicitado.\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 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 = {
"consultar_estoque",
"avaliar_veiculo_troca",
"cancelar_pedido",
"listar_pedidos",
"listar_agendamentos_revisao",

@ -1,5 +1,6 @@
import json
import logging
import re
from datetime import datetime, timedelta
from app.core.time_utils import utc_now
from time import perf_counter
@ -263,6 +264,8 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
domain_hint = self._domain_from_turn_decision(turn_decision)
if domain_hint == "general":
domain_hint = self._domain_from_intents(extracted_entities.get("intents", {}))
if self._has_trade_in_evaluation_request(routing_message, turn_decision=turn_decision):
domain_hint = "sales"
if self._should_consume_sales_follow_up_in_active_flow(
message=routing_message,
user_id=user_id,
@ -291,6 +294,15 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
if orchestration_override:
return orchestration_override
trade_in_response = await self._try_handle_trade_in_evaluation(
message=routing_message,
user_id=user_id,
extracted_entities=extracted_entities,
turn_decision=turn_decision,
)
if trade_in_response:
return await finish(trade_in_response, queue_notice=queue_notice)
decision_action = str(turn_decision.get("action") or "")
decision_response = str(turn_decision.get("response_to_user") or "").strip()
if (
@ -594,6 +606,8 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
pending_selection = self.state.get_entry("pending_stock_selections", user_id, expire=True)
if not pending_selection:
return None
if self._has_trade_in_evaluation_request(message):
return None
if not self._should_bootstrap_order_from_context(
message=message,
user_id=user_id,
@ -632,9 +646,27 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
normalized_message = self.normalizer.normalize_text(message).strip()
if self._looks_like_explicit_domain_shift_request(normalized_message):
return None
if self._has_trade_in_evaluation_request(message):
return None
if self._has_order_listing_request(message):
return None
pending_cancel_order_draft = self.state.get_entry("pending_cancel_order_drafts", user_id, expire=True)
if pending_cancel_order_draft:
response = await self._try_collect_and_cancel_order(
message=message,
user_id=user_id,
extracted_fields={},
intents={},
turn_decision={
"intent": "order_cancel",
"domain": "sales",
"action": "collect_order_cancel",
},
)
if response:
return await finish(response)
pending_order_draft = self.state.get_entry("pending_order_drafts", user_id, expire=True)
if pending_order_draft:
if self._should_restart_open_order_draft(
@ -668,21 +700,6 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
if response:
return await finish(response)
pending_cancel_order_draft = self.state.get_entry("pending_cancel_order_drafts", user_id, expire=True)
if pending_cancel_order_draft:
response = await self._try_collect_and_cancel_order(
message=message,
user_id=user_id,
extracted_fields={},
intents={},
turn_decision={
"intent": "order_cancel",
"domain": "sales",
"action": "collect_order_cancel",
},
)
if response:
return await finish(response)
return None
@ -704,6 +721,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
self._has_explicit_order_request(message)
or self._has_order_listing_request(message)
or self._has_stock_listing_request(message)
or self._has_trade_in_evaluation_request(message)
):
return None
@ -1078,7 +1096,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
return
sanitized: list[dict] = []
for item in tool_result[:20]:
for item in tool_result[:5]:
if not isinstance(item, dict):
continue
try:
@ -1374,6 +1392,19 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
or cancel_order_fields.get("motivo")
or has_open_cancel_order_draft
)
normalized_message = self._normalize_text(message or "").strip() if message else ""
if message and self._has_explicit_order_request(message):
if decision_intent not in {
"order_list",
"order_cancel",
"review_schedule",
"review_list",
"review_cancel",
"review_reschedule",
} and not any(term in normalized_message for term in {"cancel", "revisao", "agendamento", "remarcar"}):
return True
if decision_intent != "order_create":
return False
@ -1503,6 +1534,102 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
)
)
def _has_trade_in_evaluation_request(self, message: str, turn_decision: dict | None = None) -> bool:
normalized_message = self._normalize_text(message or "").strip()
normalized_tool_name = self.normalizer.normalize_tool_name((turn_decision or {}).get("tool_name"))
if normalized_tool_name == "avaliar_veiculo_troca":
return True
if not normalized_message:
return False
explicit_terms = {
"avaliar meu carro para troca",
"avaliar o meu carro para troca",
"avaliar carro para troca",
"avaliacao para troca",
"avaliar veiculo para troca",
"avaliar o veiculo para troca",
}
if any(term in normalized_message for term in explicit_terms):
return True
return "troca" in normalized_message and any(term in normalized_message for term in {"avaliar", "avaliacao", "estimativa"})
def _extract_trade_in_fields(
self,
message: str,
extracted_entities: dict | None = None,
turn_decision: dict | None = None,
) -> dict:
payload: dict = {}
entities = extracted_entities if isinstance(extracted_entities, dict) else {}
review_fields = self._normalize_review_fields(entities.get("review_fields"))
for field in ("modelo", "ano", "km"):
if field in review_fields:
payload[field] = review_fields[field]
decision_arguments = self.normalizer.normalize_tool_arguments(
"avaliar_veiculo_troca",
(turn_decision or {}).get("tool_arguments") or {},
)
for field in ("modelo", "ano", "km"):
if field in decision_arguments:
payload[field] = decision_arguments[field]
normalized_message = self._normalize_text(message).strip()
if "modelo" not in payload:
trade_in_model = re.sub(r"^.*?\b(?:troca|entrada)\b\s*[:,-]?\s*", "", normalized_message)
trade_in_model = re.split(r"(?<!\d)(?:19\d{2}|20\d{2}|2100)(?!\d)", trade_in_model, maxsplit=1)[0]
trade_in_model = re.split(
r"(?<!\d)(?:\d{1,3}(?:[.\s]\d{3})+|\d{2,6})\s*km\b",
trade_in_model,
maxsplit=1,
flags=re.IGNORECASE,
)[0]
trade_in_model = re.sub(r"^(?:um|uma|meu|minha|veiculo|carro)\s+", "", trade_in_model).strip(" ,.;:-")
cleaned_model = self._clean_review_model_candidate(trade_in_model)
if cleaned_model:
payload["modelo"] = cleaned_model
self._supplement_review_fields_from_message(message=message, payload=payload)
return {field: payload[field] for field in ("modelo", "ano", "km") if field in payload}
def _render_missing_trade_in_fields_prompt(self, missing_fields: list[str]) -> str:
labels = {
"modelo": "o modelo do veiculo",
"ano": "o ano do veiculo",
"km": "a quilometragem atual (km)",
}
itens = [f"- {labels[field]}" for field in missing_fields if field in labels]
return "Para avaliar seu veiculo na troca, preciso dos dados abaixo:\n" + "\n".join(itens)
async def _try_handle_trade_in_evaluation(
self,
message: str,
user_id: int | None,
extracted_entities: dict | None = None,
turn_decision: dict | None = None,
) -> str | None:
if not self._has_trade_in_evaluation_request(message, turn_decision=turn_decision):
return None
arguments = self._extract_trade_in_fields(
message=message,
extracted_entities=extracted_entities,
turn_decision=turn_decision,
)
missing = [field for field in ("modelo", "ano", "km") if field not in arguments]
if missing:
return self._render_missing_trade_in_fields_prompt(missing)
try:
tool_result = await self.tool_executor.execute(
"avaliar_veiculo_troca",
arguments,
user_id=user_id,
)
except HTTPException as exc:
return self._http_exception_detail(exc)
return self._fallback_format_tool_result("avaliar_veiculo_troca", tool_result)
def _parse_json_object(self, text: str):
return self.normalizer.parse_json_object(text)

@ -26,16 +26,17 @@ def fallback_format_tool_result(tool_name: str, tool_result: Any) -> str:
if tool_name == "consultar_estoque" and isinstance(tool_result, list):
if not tool_result:
return "Nao encontrei nenhum veiculo com os criterios informados."
linhas = [f"Encontrei {len(tool_result)} veiculo(s):"]
for idx, item in enumerate(tool_result[:10], start=1):
exibidos = tool_result[:5]
linhas = [f"Encontrei {len(tool_result)} veiculo(s). Estas sao as opcoes principais:"]
for idx, item in enumerate(exibidos, start=1):
modelo = item.get("modelo", "N/A")
categoria = item.get("categoria", "N/A")
preco = format_currency_br(item.get("preco"))
linhas.append(f"{idx}. {modelo} ({categoria}) - {preco}")
restantes = len(tool_result) - 10
restantes = len(tool_result) - len(exibidos)
if restantes > 0:
linhas.append(f"... e mais {restantes} veiculo(s).")
linhas.append("Para escolher, responda com o numero da opcao desejada. Exemplo: 1.")
linhas.append("Se algum te interessar, responda com o numero ou com o modelo.")
return "\n".join(linhas)
if tool_name == "cancelar_pedido" and isinstance(tool_result, dict):
@ -66,15 +67,12 @@ def fallback_format_tool_result(tool_name: str, tool_result: Any) -> str:
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):
for idx, item in enumerate(tool_result, 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):
@ -101,21 +99,12 @@ def fallback_format_tool_result(tool_name: str, tool_result: Any) -> str:
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):
for idx, item in enumerate(tool_result, start=1):
protocolo = item.get("protocolo", "N/A")
placa = item.get("placa", "N/A")
data_hora = 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).")
linhas.append(f"{idx}. {protocolo} | {placa} | {data_hora} | {status}")
return "\n".join(linhas)
if tool_name == "cancelar_agendamento_revisao" and isinstance(tool_result, dict):
@ -144,6 +133,18 @@ def fallback_format_tool_result(tool_name: str, tool_result: Any) -> str:
f"Status: {status}"
)
if tool_name == "avaliar_veiculo_troca" and isinstance(tool_result, dict):
modelo = tool_result.get("modelo", "N/A")
ano = tool_result.get("ano", "N/A")
km = tool_result.get("km", "N/A")
valor = format_currency_br(tool_result.get("valor_estimado_troca"))
return (
"Estimativa de troca concluida.\n"
f"Veiculo: {modelo} {ano}\n"
f"Quilometragem: {km} km\n"
f"Valor estimado: {valor}"
)
if tool_name == "validar_cliente_venda" and isinstance(tool_result, dict):
aprovado = tool_result.get("aprovado")
limite = format_currency_br(tool_result.get("limite_credito"))

@ -10,10 +10,11 @@ from fastapi import HTTPException
from app.services.flows.order_flow import OrderFlowMixin
from app.services.flows.review_flow import ReviewFlowMixin
from app.integrations.telegram_satellite_service import _ensure_supported_runtime_configuration
from app.integrations.telegram_satellite_service import _ensure_supported_runtime_configuration, _split_telegram_text
from app.models.tool_model import ToolDefinition
from app.services.orchestration.conversation_policy import ConversationPolicy
from app.services.orchestration.entity_normalizer import EntityNormalizer
from app.services.orchestration.response_formatter import fallback_format_tool_result
from app.services.tools.tool_registry import ToolRegistry
from app.services.tools.handlers import _parse_data_hora_revisao
@ -322,6 +323,43 @@ class ConversationAdjustmentsTests(unittest.TestCase):
):
_ensure_supported_runtime_configuration()
def test_telegram_satellite_splits_long_messages_safely(self):
text = ("A" * 3900) + "\n" + ("B" * 3900) + "\n" + ("C" * 3900)
chunks = _split_telegram_text(text)
self.assertGreater(len(chunks), 1)
self.assertTrue(all(len(chunk) <= 3800 for chunk in chunks))
rebuilt = "\n".join(chunks)
self.assertEqual(rebuilt.replace("\n", ""), text.replace("\n", ""))
self.assertIn("A" * 100, rebuilt)
self.assertIn("B" * 100, rebuilt)
self.assertIn("C" * 100, rebuilt)
def test_review_listing_formatter_is_compact_and_complete(self):
response = fallback_format_tool_result(
"listar_agendamentos_revisao",
[
{
"protocolo": "REV-1",
"placa": "ABC1234",
"data_hora": "2026-03-19T09:30:00",
"status": "agendado",
},
{
"protocolo": "REV-2",
"placa": "XYZ9999",
"data_hora": "2026-03-20T10:00:00",
"status": "cancelado",
},
],
)
self.assertIn("Voce tem 2 agendamento(s):", response)
self.assertIn("1. REV-1 | ABC1234 |", response)
self.assertIn("2. REV-2 | XYZ9999 |", response)
self.assertNotIn("\n\n", response)
def test_defer_flow_cancel_when_order_cancel_draft_waits_for_reason(self):
state = FakeState(
entries={
@ -610,6 +648,7 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
)
self.assertEqual(registry.calls[0][0], "listar_pedidos")
self.assertEqual(registry.calls[0][1]["limite"], 50)
self.assertIn("Encontrei 2 pedido(s):", response)
self.assertIsNotNone(state.get_entry("pending_order_drafts", 10))
@ -628,6 +667,42 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(response)
self.assertEqual(registry.calls, [])
async def test_order_flow_returns_clear_guidance_for_invalid_cpf_follow_up(self):
state = FakeState(
entries={
"pending_order_drafts": {
10: {
"payload": {"vehicle_id": 1, "modelo_veiculo": "Honda Civic 2021", "valor_veiculo": 48500.0},
"expires_at": utc_now() + timedelta(minutes=30),
}
}
},
contexts={
10: {
"generic_memory": {},
"shared_memory": {},
"last_stock_results": [
{"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 48500.0}
],
"selected_vehicle": {"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 48500.0},
}
}
)
registry = FakeRegistry()
flow = OrderFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_create_order(
message="123",
user_id=10,
extracted_fields={},
intents={},
turn_decision={"intent": "order_create", "domain": "sales", "action": "collect_order_create"},
)
self.assertEqual(response, "Para seguir com o pedido, preciso de um CPF valido. Pode me informar novamente?")
self.assertEqual(registry.calls, [])
self.assertIsNotNone(state.get_entry("pending_order_drafts", 10))
async def test_order_flow_auto_lists_stock_on_first_purchase_message_when_budget_exists(self):
state = FakeState(
contexts={
@ -1485,6 +1560,61 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(draft["payload"].get("cpf"), "12345678909")
self.assertNotIn("vehicle_id", draft["payload"])
async def test_order_flow_clears_draft_after_non_retryable_credit_rejection(self):
state = FakeState(
entries={
"pending_order_drafts": {
10: {
"payload": {"cpf": "12345678909", "vehicle_id": 9, "modelo_veiculo": "Hyundai HB20S 2022"},
"expires_at": utc_now() + timedelta(minutes=30),
}
}
},
contexts={
10: {
"active_domain": "sales",
"generic_memory": {"cpf": "12345678909"},
"shared_memory": {"cpf": "12345678909"},
"last_stock_results": [
{"id": 9, "modelo": "Hyundai HB20S 2022", "categoria": "sedan", "preco": 76000.0},
],
"selected_vehicle": {"id": 9, "modelo": "Hyundai HB20S 2022", "categoria": "sedan", "preco": 76000.0},
"pending_single_vehicle_confirmation": {"id": 9, "modelo": "Hyundai HB20S 2022", "categoria": "sedan", "preco": 76000.0},
}
},
)
registry = FakeRegistry()
registry.raise_http_exception = HTTPException(
status_code=400,
detail={
"code": "credit_not_approved",
"message": "Cliente nao aprovado para este valor. Limite disponivel: R$ 70878.00.",
"retryable": False,
"field": "cpf",
},
)
flow = OrderFlowHarness(state=state, registry=registry)
async def fake_hydrate_mock_customer_from_cpf(cpf: str, user_id: int | None = None):
return {"cpf": cpf, "user_id": user_id}
with patch(
"app.services.flows.order_flow.hydrate_mock_customer_from_cpf",
new=fake_hydrate_mock_customer_from_cpf,
):
response = await flow._try_collect_and_create_order(
message="12345678909",
user_id=10,
extracted_fields={},
intents={},
)
self.assertIn("nao aprovado", response)
self.assertIsNone(state.get_entry("pending_order_drafts", 10))
self.assertEqual(state.get_user_context(10)["selected_vehicle"], None)
self.assertEqual(state.get_user_context(10)["last_stock_results"], [])
self.assertEqual(state.get_user_context(10).get("pending_single_vehicle_confirmation"), None)
async def test_order_flow_refreshes_stale_stock_results_when_budget_changes(self):
state = FakeState(
contexts={
@ -2349,6 +2479,24 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
self.assertIsNotNone(draft)
self.assertEqual(draft["payload"].get("placa"), "ABC1234")
self.assertNotIn("data_hora", draft["payload"])
async def test_review_management_preserves_reason_from_single_cancel_message(self):
state = FakeState()
registry = FakeRegistry()
flow = ReviewFlowHarness(state=state, registry=registry)
response = await flow._try_handle_review_management(
message="Quero cancelar a revisao do protocolo REV-20260313-F754AF27 porque nao vou poder ir",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "review_cancel", "domain": "review", "action": "answer_user"},
)
self.assertEqual(registry.calls[0][0], "cancelar_agendamento_revisao")
self.assertEqual(registry.calls[0][1]["protocolo"], "REV-20260313-F754AF27")
self.assertEqual(registry.calls[0][1]["motivo"], "nao vou poder ir")
self.assertIn("cancelar_agendamento_revisao", response)
async def test_review_management_infers_cancel_intent_from_protocol_message(self):
state = FakeState()
@ -2454,6 +2602,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
)
self.assertEqual(registry.calls[0][0], "listar_agendamentos_revisao")
self.assertEqual(registry.calls[0][1]["limite"], 100)
self.assertIn("listar_agendamentos_revisao", response)
async def test_review_schedule_clears_open_management_draft(self):
@ -2727,4 +2876,3 @@ class ToolRegistryExecutionTests(unittest.IsolatedAsyncioTestCase):
if __name__ == "__main__":
unittest.main()

@ -951,11 +951,6 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
service._fallback_format_tool_result = lambda tool_name, tool_result: (
f"Pedido {tool_result['numero_pedido']} atualizado.\nStatus: {tool_result['status']}"
)
async def fake_render_tool_response_with_fallback(**kwargs):
return "nao deveria usar llm"
service._render_tool_response_with_fallback = fake_render_tool_response_with_fallback
service._http_exception_detail = lambda exc: str(exc)
service._is_low_value_response = lambda text: False
@ -1438,6 +1433,152 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("Encontrei 2 veiculo(s):", response)
async def test_handle_message_prioritizes_order_flow_for_explicit_order_request_without_extracted_fields(self):
state = FakeState(
contexts={
1: {
"active_domain": "general",
"generic_memory": {},
"shared_memory": {},
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
}
}
)
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
service.policy = ConversationPolicy(service=service)
service._empty_extraction_payload = service.normalizer.empty_extraction_payload
service._log_turn_event = lambda *args, **kwargs: None
service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response
async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None):
return base_response
service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order
service._upsert_user_context = lambda user_id: None
async def fake_extract_turn_decision(message: str, user_id: int | None):
return {
"intent": "general",
"domain": "general",
"action": "answer_user",
"entities": {
"generic_memory": {},
"review_fields": {},
"review_management_fields": {},
"order_fields": {},
"cancel_order_fields": {},
},
"missing_fields": [],
"selection_index": None,
"tool_name": None,
"tool_arguments": {},
"response_to_user": "Claro! Voce gostaria de fazer um pedido de um veiculo ou agendar uma revisao?",
}
service._extract_turn_decision_with_llm = fake_extract_turn_decision
async def fake_try_resolve_pending_order_selection(**kwargs):
return None
service._try_resolve_pending_order_selection = fake_try_resolve_pending_order_selection
async def fake_try_continue_queued_order(**kwargs):
return None
service._try_continue_queued_order = fake_try_continue_queued_order
async def fake_extract_message_plan(message: str, user_id: int | None):
return {
"orders": [
{
"domain": "sales",
"message": message,
"entities": service.normalizer.empty_extraction_payload(),
}
]
}
service._extract_message_plan_with_llm = fake_extract_message_plan
service._prepare_message_for_single_order = lambda message, user_id, routing_plan=None: (message, None, None)
service._resolve_entities_for_message_plan = lambda message_plan, routed_message: service.normalizer.empty_extraction_payload()
async def fake_extract_entities(message: str, user_id: int | None):
return {
"generic_memory": {},
"review_fields": {},
"review_management_fields": {},
"order_fields": {},
"cancel_order_fields": {},
"intents": {},
}
service._extract_entities_with_llm = fake_extract_entities
async def fake_extract_missing_sales_search_context_with_llm(**kwargs):
return {}
service._extract_missing_sales_search_context_with_llm = fake_extract_missing_sales_search_context_with_llm
service._domain_from_intents = lambda intents: "general"
service._handle_context_switch = lambda **kwargs: None
service._update_active_domain = lambda **kwargs: None
async def fake_try_execute_orchestration_control_tool(**kwargs):
return None
service._try_execute_orchestration_control_tool = fake_try_execute_orchestration_control_tool
async def fake_try_execute_business_tool_from_turn_decision(**kwargs):
return None
service._try_execute_business_tool_from_turn_decision = fake_try_execute_business_tool_from_turn_decision
async def fake_try_handle_review_management(**kwargs):
return None
service._try_handle_review_management = fake_try_handle_review_management
async def fake_try_confirm_pending_review(**kwargs):
return None
service._try_confirm_pending_review = fake_try_confirm_pending_review
async def fake_try_collect_and_schedule_review(**kwargs):
return None
service._try_collect_and_schedule_review = fake_try_collect_and_schedule_review
async def fake_try_collect_and_cancel_order(**kwargs):
return None
service._try_collect_and_cancel_order = fake_try_collect_and_cancel_order
async def fake_try_handle_order_listing(**kwargs):
return None
service._try_handle_order_listing = fake_try_handle_order_listing
async def fake_try_collect_and_create_order(**kwargs):
return (
"Para seguir com o pedido, me diga qual carro voce procura.\n"
"Se preferir, posso listar opcoes por faixa de preco, modelo ou tipo de carro."
)
service._try_collect_and_create_order = fake_try_collect_and_create_order
response = await service.handle_message(
"Quero fazer um pedido",
user_id=1,
)
self.assertIn("Para seguir com o pedido", response)
self.assertNotIn("Qual veiculo voce gostaria de pedir", response)
def test_should_prioritize_review_flow_when_review_draft_is_open(self):
state = FakeState(
entries={
@ -2568,6 +2709,70 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(response)
self.assertIsNotNone(state.get_entry("pending_order_drafts", 1))
async def test_active_sales_follow_up_prioritizes_cancel_order_over_stale_order_create_draft(self):
state = FakeState(
entries={
"pending_order_drafts": {
1: {
"payload": {
"cpf": "12345678909",
"vehicle_id": 15,
"modelo_veiculo": "Volkswagen T-Cross 2022",
"valor_veiculo": 73224.0,
},
"expires_at": utc_now() + timedelta(minutes=15),
}
},
"pending_cancel_order_drafts": {
1: {
"payload": {"numero_pedido": "PED-20260312110556-BBA37F"},
"expires_at": utc_now() + timedelta(minutes=15),
}
},
},
contexts={
1: {
"active_domain": "sales",
"active_task": "order_cancel",
"generic_memory": {"cpf": "12345678909"},
"shared_memory": {"cpf": "12345678909"},
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
}
},
)
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
service._get_user_context = lambda user_id: state.get_user_context(user_id)
service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context)
async def fake_try_collect_and_create_order(**kwargs):
raise AssertionError("nao deveria priorizar order_create enquanto order_cancel aguarda motivo")
async def fake_try_collect_and_cancel_order(**kwargs):
self.assertEqual(kwargs["message"], "desisti da compra")
self.assertEqual(kwargs["user_id"], 1)
return "Pedido PED-20260312110556-BBA37F atualizado.\nStatus: Cancelado"
service._try_collect_and_create_order = fake_try_collect_and_create_order
service._try_collect_and_cancel_order = fake_try_collect_and_cancel_order
async def finish(response: str, queue_notice: str | None = None):
return response
response = await service._try_handle_active_sales_follow_up(
message="desisti da compra",
user_id=1,
finish=finish,
)
self.assertIn("Pedido PED-20260312110556-BBA37F atualizado.", response)
self.assertIn("Status: Cancelado", response)
async def test_active_sales_follow_up_allows_new_budget_search_to_reset_open_order_draft(self):
state = FakeState(
entries={
@ -2746,57 +2951,135 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertTrue(prioritized)
async def test_pending_order_selection_prefers_turn_decision_domain(self):
def test_should_prioritize_order_flow_for_explicit_order_request_without_entities(self):
state = FakeState(
contexts={
9: {
"pending_order_selection": {
"orders": [
{"domain": "review", "message": "agendar revisao", "memory_seed": {}},
{"domain": "sales", "message": "fazer pedido", "memory_seed": {}},
],
"expires_at": utc_now() + timedelta(minutes=15),
},
"order_queue": [],
1: {
"active_domain": "general",
"generic_memory": {},
"shared_memory": {},
"last_stock_results": [],
"selected_vehicle": None,
}
}
)
policy = ConversationPolicy(service=FakePolicyService(state))
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
service._get_user_context = lambda user_id: state.get_user_context(user_id)
response = await policy.try_resolve_pending_order_selection(
message="quero comprar",
user_id=9,
turn_decision={"domain": "sales", "intent": "order_create", "action": "collect_order_create"},
prioritized = service._should_prioritize_order_flow(
turn_decision={"intent": "order_create", "domain": "sales", "action": "answer_user"},
extracted_entities={
"generic_memory": {},
"review_fields": {},
"review_management_fields": {},
"order_fields": {},
"cancel_order_fields": {},
"intents": {},
},
user_id=1,
message="Quero fazer um pedido",
)
self.assertIn("Vou comecar por: Venda: fazer pedido", response)
self.assertTrue(prioritized)
async def test_pending_order_selection_prefers_turn_decision_selection_index(self):
def test_should_prioritize_order_flow_for_explicit_order_request_even_when_model_returns_general(self):
state = FakeState(
contexts={
9: {
"pending_order_selection": {
"orders": [
{"domain": "review", "message": "agendar revisao", "memory_seed": {}},
{"domain": "sales", "message": "fazer pedido", "memory_seed": {}},
],
"expires_at": utc_now() + timedelta(minutes=15),
},
"order_queue": [],
1: {
"active_domain": "general",
"generic_memory": {},
"shared_memory": {},
"last_stock_results": [],
"selected_vehicle": None,
}
}
)
policy = ConversationPolicy(service=FakePolicyService(state))
response = await policy.try_resolve_pending_order_selection(
message="esse",
user_id=9,
turn_decision={"domain": "general", "intent": "general", "action": "answer_user", "selection_index": 1},
)
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
service._get_user_context = lambda user_id: state.get_user_context(user_id)
prioritized = service._should_prioritize_order_flow(
turn_decision={"intent": "general", "domain": "general", "action": "answer_user"},
extracted_entities={
"generic_memory": {},
"review_fields": {},
"review_management_fields": {},
"order_fields": {},
"cancel_order_fields": {},
"intents": {},
},
user_id=1,
message="Quero fazer um pedido",
)
self.assertTrue(prioritized)
def test_normalize_tool_name_maps_trade_in_alias_and_arguments(self):
normalizer = EntityNormalizer()
tool_name = normalizer.normalize_tool_name("avaliar_troca_veiculo")
arguments = normalizer.normalize_tool_arguments(
tool_name,
{"modelo": "Onix", "ano": 2020, "quilometragem": 45000},
)
self.assertEqual(tool_name, "avaliar_veiculo_troca")
self.assertEqual(arguments, {"modelo": "Onix", "ano": 2020, "km": 45000})
async def test_pending_order_selection_prefers_turn_decision_domain(self):
state = FakeState(
contexts={
9: {
"pending_order_selection": {
"orders": [
{"domain": "review", "message": "agendar revisao", "memory_seed": {}},
{"domain": "sales", "message": "fazer pedido", "memory_seed": {}},
],
"expires_at": utc_now() + timedelta(minutes=15),
},
"order_queue": [],
"active_domain": "general",
"generic_memory": {},
}
}
)
policy = ConversationPolicy(service=FakePolicyService(state))
response = await policy.try_resolve_pending_order_selection(
message="quero comprar",
user_id=9,
turn_decision={"domain": "sales", "intent": "order_create", "action": "collect_order_create"},
)
self.assertIn("Vou comecar por: Venda: fazer pedido", response)
async def test_pending_order_selection_prefers_turn_decision_selection_index(self):
state = FakeState(
contexts={
9: {
"pending_order_selection": {
"orders": [
{"domain": "review", "message": "agendar revisao", "memory_seed": {}},
{"domain": "sales", "message": "fazer pedido", "memory_seed": {}},
],
"expires_at": utc_now() + timedelta(minutes=15),
},
"order_queue": [],
"active_domain": "general",
"generic_memory": {},
}
}
)
policy = ConversationPolicy(service=FakePolicyService(state))
response = await policy.try_resolve_pending_order_selection(
message="esse",
user_id=9,
turn_decision={"domain": "general", "intent": "general", "action": "answer_user", "selection_index": 1},
)
self.assertIn("Vou comecar por: Venda: fazer pedido", response)
@ -2898,5 +3181,329 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(service._get_user_context(9).get("order_queue"), [])
async def test_tool_continuar_proximo_pedido_reports_empty_queue(self):
state = FakeState(
contexts={
1: {
"active_domain": "sales",
"generic_memory": {},
"shared_memory": {},
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
}
}
)
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
service.policy = ConversationPolicy(service=service)
service._get_user_context = lambda user_id: state.get_user_context(user_id)
service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context)
response = await service._tool_continuar_proximo_pedido(user_id=1)
self.assertEqual(response, "Nao ha pedidos pendentes na fila para continuar.")
async def test_tool_cancelar_fluxo_atual_preserves_queue(self):
state = FakeState(
entries={
"pending_order_drafts": {
1: {
"payload": {"vehicle_id": 7},
"expires_at": utc_now() + timedelta(minutes=15),
}
}
},
contexts={
1: {
"active_domain": "sales",
"active_task": "order_create",
"generic_memory": {"orcamento_max": 70000},
"shared_memory": {"orcamento_max": 70000},
"order_queue": [{"domain": "review", "message": "agendar revisao"}],
"pending_order_selection": None,
"pending_switch": None,
"last_stock_results": [{"id": 7, "modelo": "Fiat Argo 2020", "categoria": "suv", "preco": 61857.0}],
"selected_vehicle": {"id": 7, "modelo": "Fiat Argo 2020", "categoria": "suv", "preco": 61857.0},
}
}
)
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
service.policy = ConversationPolicy(service=service)
service._get_user_context = lambda user_id: state.get_user_context(user_id)
service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context)
result = await service._tool_cancelar_fluxo_atual(user_id=1)
self.assertEqual(result["message"], "Fluxo atual de compra de veiculo cancelado.")
self.assertIsNone(state.get_entry("pending_order_drafts", 1))
self.assertEqual(state.get_user_context(1)["order_queue"], [{"domain": "review", "message": "agendar revisao"}])
async def test_tool_descartar_pedidos_pendentes_preserves_active_flow(self):
state = FakeState(
entries={
"pending_order_drafts": {
1: {
"payload": {"vehicle_id": 7},
"expires_at": utc_now() + timedelta(minutes=15),
}
}
},
contexts={
1: {
"active_domain": "sales",
"active_task": "order_create",
"generic_memory": {"orcamento_max": 70000},
"shared_memory": {"orcamento_max": 70000},
"order_queue": [{"domain": "review", "message": "agendar revisao"}],
"pending_order_selection": {
"orders": [
{"domain": "sales", "message": "comprar carro"},
{"domain": "review", "message": "remarcar revisao"},
],
"expires_at": utc_now() + timedelta(minutes=15),
},
"pending_switch": {
"target_domain": "review",
"queued_message": "agendar revisao",
"expires_at": utc_now() + timedelta(minutes=15),
},
"last_stock_results": [],
"selected_vehicle": None,
}
}
)
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
service.policy = ConversationPolicy(service=service)
service._get_user_context = lambda user_id: state.get_user_context(user_id)
service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context)
result = await service._tool_descartar_pedidos_pendentes(user_id=1)
self.assertEqual(result["message"], "Descartei 4 pedidos pendentes da fila.")
self.assertIsNotNone(state.get_entry("pending_order_drafts", 1))
self.assertEqual(state.get_user_context(1)["order_queue"], [])
self.assertIsNone(state.get_user_context(1)["pending_order_selection"])
self.assertIsNone(state.get_user_context(1)["pending_switch"])
async def test_handle_message_prioritizes_trade_in_evaluation_over_freeform_answer(self):
state = FakeState(
contexts={
1: {
"active_domain": "general",
"generic_memory": {},
"shared_memory": {},
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
}
}
)
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
service.policy = ConversationPolicy(service=service)
service.tool_executor = FakeToolExecutor(
result={
"modelo": "Onix",
"ano": 2020,
"km": 45000,
"valor_estimado_troca": 65432.0,
}
)
service._empty_extraction_payload = service.normalizer.empty_extraction_payload
service._log_turn_event = lambda *args, **kwargs: None
service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response
service._fallback_format_tool_result = lambda tool_name, tool_result: (
"Estimativa de troca concluida.\n"
f"Veiculo: {tool_result['modelo']} {tool_result['ano']}\n"
f"Quilometragem: {tool_result['km']} km\n"
f"Valor estimado: R$ {tool_result['valor_estimado_troca']:.2f}"
)
service._http_exception_detail = lambda exc: str(exc)
service._get_user_context = lambda user_id: state.get_user_context(user_id)
service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context)
async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None):
return base_response
service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order
service._upsert_user_context = lambda user_id: None
async def fake_try_handle_pending_stock_selection_follow_up(**kwargs):
return None
service._try_handle_pending_stock_selection_follow_up = fake_try_handle_pending_stock_selection_follow_up
async def fake_try_handle_active_sales_follow_up(**kwargs):
return None
service._try_handle_active_sales_follow_up = fake_try_handle_active_sales_follow_up
async def fake_try_handle_active_review_follow_up(**kwargs):
return None
service._try_handle_active_review_follow_up = fake_try_handle_active_review_follow_up
async def fake_try_handle_immediate_context_reset(**kwargs):
return None
service._try_handle_immediate_context_reset = fake_try_handle_immediate_context_reset
async def fake_try_resolve_pending_order_selection(**kwargs):
return None
service._try_resolve_pending_order_selection = fake_try_resolve_pending_order_selection
async def fake_try_continue_queued_order(**kwargs):
return None
service._try_continue_queued_order = fake_try_continue_queued_order
async def fake_extract_turn_decision(message: str, user_id: int | None):
return {
"intent": "general",
"domain": "general",
"action": "answer_user",
"entities": service.normalizer.empty_extraction_payload(),
"missing_fields": [],
"selection_index": None,
"tool_name": None,
"tool_arguments": {},
"response_to_user": "Legal! Para fazer a avaliacao, preciso da versao e da placa.",
}
service._extract_turn_decision_with_llm = fake_extract_turn_decision
async def fake_extract_message_plan(message: str, user_id: int | None):
return {
"orders": [
{
"domain": "sales",
"message": message,
"entities": service.normalizer.empty_extraction_payload(),
}
]
}
service._extract_message_plan_with_llm = fake_extract_message_plan
service._prepare_message_for_single_order = lambda message, user_id, routing_plan=None: (message, None, None)
service._resolve_entities_for_message_plan = lambda message_plan, routed_message: service.normalizer.empty_extraction_payload()
async def fake_extract_entities(message: str, user_id: int | None):
return service.normalizer.empty_extraction_payload()
service._extract_entities_with_llm = fake_extract_entities
async def fake_extract_missing_sales_search_context_with_llm(**kwargs):
return {}
service._extract_missing_sales_search_context_with_llm = fake_extract_missing_sales_search_context_with_llm
service._domain_from_intents = lambda intents: "general"
service._handle_context_switch = lambda **kwargs: None
service._update_active_domain = lambda **kwargs: None
async def fake_try_execute_orchestration_control_tool(**kwargs):
return None
service._try_execute_orchestration_control_tool = fake_try_execute_orchestration_control_tool
async def fake_try_handle_review_management(**kwargs):
raise AssertionError("nao deveria entrar em gerenciamento de revisao para avaliacao de troca")
service._try_handle_review_management = fake_try_handle_review_management
async def fake_try_confirm_pending_review(**kwargs):
raise AssertionError("nao deveria entrar em confirmacao de revisao para avaliacao de troca")
service._try_confirm_pending_review = fake_try_confirm_pending_review
async def fake_try_collect_and_schedule_review(**kwargs):
raise AssertionError("nao deveria entrar em agendamento de revisao para avaliacao de troca")
service._try_collect_and_schedule_review = fake_try_collect_and_schedule_review
async def fake_try_collect_and_cancel_order(**kwargs):
raise AssertionError("nao deveria entrar em cancelamento de pedido para avaliacao de troca")
service._try_collect_and_cancel_order = fake_try_collect_and_cancel_order
async def fake_try_handle_order_listing(**kwargs):
raise AssertionError("nao deveria entrar em listagem de pedidos para avaliacao de troca")
service._try_handle_order_listing = fake_try_handle_order_listing
async def fake_try_collect_and_create_order(**kwargs):
raise AssertionError("nao deveria entrar em compra para avaliacao de troca")
service._try_collect_and_create_order = fake_try_collect_and_create_order
response = await service.handle_message(
"Quero avaliar meu carro para troca: Onix 2020, 45000 km",
user_id=1,
)
self.assertIn("Estimativa de troca concluida", response)
self.assertEqual(
service.tool_executor.calls,
[("avaliar_veiculo_troca", {"modelo": "Onix", "ano": 2020, "km": 45000}, 1)],
)
async def test_active_review_follow_up_ignores_trade_in_request(self):
state = FakeState(
entries={
"pending_review_drafts": {
1: {
"payload": {"placa": "ABC1234"},
"expires_at": utc_now() + timedelta(minutes=15),
}
}
},
contexts={
1: {
"active_domain": "review",
"generic_memory": {},
"shared_memory": {},
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
}
}
)
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
service._get_user_context = lambda user_id: state.get_user_context(user_id)
service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context)
async def fake_try_collect_and_schedule_review(**kwargs):
raise AssertionError("nao deveria consumir avaliacao de troca como follow-up de revisao")
service._try_collect_and_schedule_review = fake_try_collect_and_schedule_review
async def finish(response: str, queue_notice: str | None = None):
return response
response = await service._try_handle_active_review_follow_up(
message="Quero avaliar meu carro para troca: Onix 2020, 45000 km",
user_id=1,
finish=finish,
)
self.assertIsNone(response)
if __name__ == "__main__":
unittest.main()

Loading…
Cancel
Save