🐛 fix(review): blindar agendamento e gestao de revisoes no fluxo estruturado

- prioriza o fluxo de agendamento e reuso do ultimo veiculo sobre respostas livres e remarcacao sem protocolo

- normaliza aliases de tools e argumentos de revisao e rebaixa call_tool incompleto para coleta incremental

- impede listagem de pedidos em mensagens de agendamentos e reforca respostas deterministicas de revisao

- extrai data/hora valida de frases longas e descarta ruido invalido em data_hora

- adiciona logs de progresso e amplia a cobertura de testes conversacionais e do contrato estruturado
main
parent f5a7a720ed
commit 95f3ed2f6b

@ -22,9 +22,17 @@ class OrderFlowMixin:
return str((turn_decision or {}).get("intent") or "").strip().lower()
def _has_order_listing_request(self, message: str, turn_decision: dict | None = None) -> bool:
normalized = self._normalize_text(message).strip()
review_listing_terms = {
"agendamento",
"agendamentos",
"revisao",
"revisoes",
}
if any(term in normalized for term in review_listing_terms):
return False
if self._decision_intent(turn_decision) == "order_list":
return True
normalized = self._normalize_text(message).strip()
listing_terms = {
"meus pedidos",
"meu pedido",
@ -407,6 +415,8 @@ class OrderFlowMixin:
return None
normalized_intents = self._normalize_intents(intents)
if any(term in self._normalize_text(message).strip() for term in {"agendamento", "agendamentos", "revisao", "revisoes"}):
return None
has_intent = (
self._decision_intent(turn_decision) == "order_list"
or normalized_intents.get("order_list", False)

@ -15,6 +15,116 @@ class ReviewFlowMixin:
def _decision_intent(self, turn_decision: dict | None) -> str:
return str((turn_decision or {}).get("intent") or "").strip().lower()
def _log_review_flow_source(
self,
source: str,
payload: dict | None = None,
missing_fields: list[str] | None = None,
) -> None:
if not hasattr(self, "_log_turn_event"):
return
self._log_turn_event(
"review_flow_progress",
review_flow_source=source,
payload_keys=sorted((payload or {}).keys()),
missing_fields=list(missing_fields or []),
)
def _active_domain(self, user_id: int | None) -> str:
if user_id is None or not hasattr(self, "_get_user_context"):
return "general"
context = self._get_user_context(user_id)
if not isinstance(context, dict):
return "general"
return str(context.get("active_domain") or "general").strip().lower()
def _supplement_review_fields_from_message(self, message: str, payload: dict) -> None:
if not isinstance(payload, dict):
return
normalized_message = self._normalize_text(message).strip()
if "placa" not in payload:
for token in str(message or "").split():
normalized_plate = self.normalizer.normalize_plate(token)
if normalized_plate:
payload["placa"] = normalized_plate
break
if "data_hora" not in payload:
normalized_datetime = self._normalize_review_datetime_text(message)
if normalized_datetime and normalized_datetime != str(message or "").strip():
payload["data_hora"] = normalized_datetime
if "km" not in payload:
km_match = re.search(r"(?<!\d)(\d{1,3}(?:[.\s]\d{3})+|\d{2,6})\s*km\b", normalized_message, flags=re.IGNORECASE)
if km_match:
km_value = self.normalizer.normalize_positive_number(km_match.group(1))
if km_value:
payload["km"] = int(round(km_value))
if "revisao_previa_concessionaria" not in payload:
if any(term in normalized_message for term in {"nunca fiz revisao", "nao fiz revisao", "nunca revisei"}):
payload["revisao_previa_concessionaria"] = False
elif any(term in normalized_message for term in {"ja fiz revisao", "fiz revisao", "ja revisei"}):
payload["revisao_previa_concessionaria"] = True
if "ano" not in payload:
year_match = re.search(r"(?<!\d)(19\d{2}|20\d{2}|2100)(?!\d)", normalized_message)
if year_match:
payload["ano"] = int(year_match.group(1))
if "modelo" not in payload:
model_match = re.search(
r"(?:modelo do meu carro (?:e|eh)?|meu carro (?:e|eh)?|carro (?:e|eh)?|veiculo (?:e|eh)?)\s+([a-z0-9][a-z0-9\s-]{1,30})",
normalized_message,
flags=re.IGNORECASE,
)
if model_match:
raw_model = model_match.group(1)
raw_model = re.split(r"\b(?:ele e|ele eh|ano|placa|km|quilometragem|data|amanha|hoje)\b", raw_model, maxsplit=1)[0]
raw_model = raw_model.strip(" ,.;:-")
raw_model = re.sub(r"\be\b$", "", raw_model).strip(" ,.;:-")
if raw_model:
payload["modelo"] = raw_model.title()
def _infer_review_management_action(
self,
message: str,
extracted_fields: dict | None = None,
) -> str | None:
normalized_message = self._normalize_text(message).strip()
management_fields = self._normalize_review_management_fields(extracted_fields)
has_protocol = bool(management_fields.get("protocolo") or self._extract_review_protocol_from_text(message))
if any(term in normalized_message for term in {"agendamento", "agendamentos"}) and any(
term in normalized_message for term in {"listar", "liste", "mostrar", "mostre", "ver", "consultar"}
):
return "list"
if not has_protocol:
return None
if any(term in normalized_message for term in {"remarcar", "reagendar", "alterar data", "mudar data", "trocar data"}):
return "reschedule"
if any(term in normalized_message for term in {"cancelar", "cancelamento", "desmarcar"}):
return "cancel"
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 {}
if normalized_payload:
return True
explicit_review_terms = {
"agendar revisao",
"marcar revisao",
"nova revisao",
"revisao agora",
"revisao",
}
return any(term in normalized_message for term in explicit_review_terms)
async def _try_handle_review_management(
self,
message: str,
@ -27,11 +137,38 @@ class ReviewFlowMixin:
return None
normalized_intents = self._normalize_intents(intents)
draft = self.state.get_entry("pending_review_management_drafts", user_id, expire=True)
schedule_draft = self.state.get_entry("pending_review_drafts", user_id, expire=True)
pending_reuse = self.state.get_entry("pending_review_reuse_confirmations", user_id, expire=True)
decision_intent = self._decision_intent(turn_decision)
inferred_action = self._infer_review_management_action(message=message, extracted_fields=extracted_fields)
normalized_fields = self._normalize_review_management_fields(extracted_fields)
protocol_in_message = normalized_fields.get("protocolo") or self._extract_review_protocol_from_text(message)
open_schedule_context = bool(schedule_draft or pending_reuse)
has_list_intent = (
decision_intent == "review_list"
or normalized_intents.get("review_list", False)
or inferred_action == "list"
)
has_cancel_intent = (
decision_intent == "review_cancel"
or normalized_intents.get("review_cancel", False)
or inferred_action == "cancel"
)
has_reschedule_intent = (
decision_intent == "review_reschedule"
or normalized_intents.get("review_reschedule", False)
or inferred_action == "reschedule"
)
has_list_intent = decision_intent == "review_list" or normalized_intents.get("review_list", False)
has_cancel_intent = decision_intent == "review_cancel" or normalized_intents.get("review_cancel", False)
has_reschedule_intent = decision_intent == "review_reschedule" or normalized_intents.get("review_reschedule", False)
if open_schedule_context and not protocol_in_message and inferred_action is None:
return None
if (decision_intent == "review_schedule" or normalized_intents.get("review_schedule", False)) and inferred_action is None:
if draft is not None:
self.state.pop_entry("pending_review_management_drafts", user_id)
draft = None
return None
if has_list_intent:
self._reset_pending_review_states(user_id=user_id)
@ -154,9 +291,21 @@ class ReviewFlowMixin:
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:
def _render_review_reuse_question(self, payload: dict | None = None) -> str:
package = payload if isinstance(payload, dict) else {}
plate = str(package.get("placa") or "").strip()
model = str(package.get("modelo") or "").strip()
vehicle_label = ""
if plate and model:
vehicle_label = f" do ultimo veiculo ({model}, placa {plate})"
elif plate:
vehicle_label = f" do ultimo veiculo (placa {plate})"
elif model:
vehicle_label = f" do ultimo veiculo ({model})"
return (
"Deseja usar os mesmos dados do ultimo veiculo e informar so a data/hora da revisao? "
f"Posso reutilizar os dados{vehicle_label} e voce me passa so a nova data/hora da revisao? "
"(sim/nao)"
)
@ -214,6 +363,8 @@ class ReviewFlowMixin:
or normalized_intents.get("review_cancel", False)
or normalized_intents.get("review_reschedule", False)
)
if self._infer_review_management_action(message=message, extracted_fields=extracted_fields):
return None
if has_management_intent:
self.state.pop_entry("pending_review_drafts", user_id)
@ -223,6 +374,8 @@ class ReviewFlowMixin:
draft = self.state.get_entry("pending_review_drafts", user_id, expire=True)
extracted = self._normalize_review_fields(extracted_fields)
pending_reuse = self.state.get_entry("pending_review_reuse_confirmations", user_id, expire=True)
active_review_context = self._active_domain(user_id) == "review"
review_flow_source = "draft" if draft else None
if pending_reuse:
should_reuse = False
@ -232,7 +385,8 @@ class ReviewFlowMixin:
elif self._is_affirmative_message(message) or "data_hora" in extracted:
should_reuse = True
else:
return self._render_review_reuse_question()
self._log_review_flow_source(source="last_review_package", payload=pending_reuse.get("payload"))
return self._render_review_reuse_question(pending_reuse.get("payload"))
if should_reuse:
seed_payload = dict(pending_reuse.get("payload") or {})
@ -245,8 +399,10 @@ class ReviewFlowMixin:
for key, value in seed_payload.items():
draft["payload"].setdefault(key, value)
self.state.pop_entry("pending_review_reuse_confirmations", user_id)
review_flow_source = "last_review_package"
if "data_hora" not in extracted:
self.state.set_entry("pending_review_drafts", user_id, draft)
self._log_review_flow_source(source=review_flow_source, payload=draft["payload"], missing_fields=["data_hora"])
return "Perfeito. Me informe apenas a data e hora desejada para a revisao."
if has_intent and draft is None and not extracted:
@ -260,7 +416,8 @@ class ReviewFlowMixin:
"expires_at": datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES),
},
)
return self._render_review_reuse_question()
self._log_review_flow_source(source="last_review_package", payload=last_package)
return self._render_review_reuse_question(last_package)
if (
draft
@ -275,18 +432,28 @@ class ReviewFlowMixin:
self.state.pop_entry("pending_review_drafts", user_id)
return None
if not has_intent and draft is None:
bootstrap_payload = dict(extracted)
self._supplement_review_fields_from_message(message=message, payload=bootstrap_payload)
self._try_prefill_review_fields_from_memory(user_id=user_id, payload=bootstrap_payload)
should_bootstrap_from_context = (
active_review_context
and self._should_bootstrap_review_from_active_context(message=message, payload=bootstrap_payload)
)
if not has_intent and draft is None and not should_bootstrap_from_context:
return None
if draft is None:
# Cria um draft com TTL para permitir coleta do agendamento
# em varias mensagens sem perder o progresso.
review_flow_source = "active_domain_fallback" if should_bootstrap_from_context and not has_intent else "intent_bootstrap"
draft = {
"payload": {},
"payload": dict(bootstrap_payload),
"expires_at": datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES),
}
draft["payload"].update(extracted)
self._supplement_review_fields_from_message(message=message, payload=draft["payload"])
self._try_prefill_review_fields_from_memory(user_id=user_id, payload=draft["payload"])
if (
"revisao_previa_concessionaria" not in draft["payload"]
@ -302,6 +469,7 @@ class ReviewFlowMixin:
missing = [field for field in REVIEW_REQUIRED_FIELDS if field not in draft["payload"]]
if missing:
self._log_review_flow_source(source=review_flow_source or "draft", payload=draft["payload"], missing_fields=missing)
return self._render_missing_review_fields_prompt(missing)
try:
@ -322,8 +490,10 @@ class ReviewFlowMixin:
draft["payload"].pop(str(error["field"]), None)
draft["expires_at"] = datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES)
self.state.set_entry("pending_review_drafts", user_id, draft)
self._log_review_flow_source(source=review_flow_source or "draft", payload=draft["payload"])
return self._http_exception_detail(exc)
self.state.pop_entry("pending_review_drafts", user_id)
self._store_last_review_package(user_id=user_id, payload=draft["payload"])
self._log_review_flow_source(source=review_flow_source or "draft", payload=draft["payload"])
return self._fallback_format_tool_result("agendar_revisao", tool_result)

@ -14,6 +14,20 @@ logger = logging.getLogger(__name__)
# Essa classe concentra normalizacao tecnica e coercoes estruturadas.
# A semantica conversacional idealmente vem do modelo, nao daqui.
class EntityNormalizer:
_TOOL_NAME_ALIASES = {
"marcar_revisao": "agendar_revisao",
"agendar revisao": "agendar_revisao",
"schedule_review": "agendar_revisao",
"list_reviews": "listar_agendamentos_revisao",
"listar_revisoes": "listar_agendamentos_revisao",
"listar_agendamentos": "listar_agendamentos_revisao",
"listar_agendamento": "listar_agendamentos_revisao",
"cancel_review": "cancelar_agendamento_revisao",
"cancelar_revisao": "cancelar_agendamento_revisao",
"cancelar_agendamento": "cancelar_agendamento_revisao",
"reschedule_review": "editar_data_revisao",
"remarcar_revisao": "editar_data_revisao",
}
_TURN_INTENT_ALIASES = {
"create_order": "order_create",
"place_order": "order_create",
@ -23,6 +37,10 @@ class EntityNormalizer:
"cancel_order": "order_cancel",
"list_orders": "order_list",
"show_orders": "order_list",
"list_reviews": "review_list",
"show_reviews": "review_list",
"cancel_review": "review_cancel",
"reschedule_review": "review_reschedule",
"list_inventory": "inventory_search",
"search_inventory": "inventory_search",
"clear_conversation": "conversation_reset",
@ -71,10 +89,26 @@ class EntityNormalizer:
"data_hora": "nova_data_hora",
"new_datetime": "nova_data_hora",
},
"agendar_revisao": {
"placa_veiculo": "placa",
"vehicle_plate": "placa",
"modelo_veiculo": "modelo",
"vehicle_model": "modelo",
"ano_veiculo": "ano",
"vehicle_year": "ano",
"quilometragem": "km",
"quilometragem_atual": "km",
"vehicle_km": "km",
"data": "data_hora",
"datetime": "data_hora",
"reviewed_before": "revisao_previa_concessionaria",
"revisao_previa": "revisao_previa_concessionaria",
},
}
_TOOL_REQUIRED_ARGUMENTS = {
"cancelar_pedido": ("numero_pedido", "motivo"),
"realizar_pedido": ("cpf", "vehicle_id"),
"agendar_revisao": ("placa", "data_hora", "modelo", "ano", "km", "revisao_previa_concessionaria"),
"editar_data_revisao": ("protocolo", "nova_data_hora"),
"cancelar_agendamento_revisao": ("protocolo",),
}
@ -207,7 +241,9 @@ class EntityNormalizer:
if isinstance(entities, dict):
normalized["entities"] = dict(entities)
tool_name = str(normalized.get("tool_name") or "").strip()
tool_name = self.normalize_tool_name(normalized.get("tool_name"))
if tool_name:
normalized["tool_name"] = tool_name
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)
@ -302,6 +338,21 @@ class EntityNormalizer:
payload["response_to_user"] = None
return payload
if tool_name == "agendar_revisao" and str(payload.get("domain") or "") == "review":
review_entities = self.normalize_review_fields(
{
**(entities.get("review_fields") or {}),
**tool_arguments,
}
)
entities["review_fields"] = review_entities
payload["action"] = "collect_review_schedule"
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(
{
@ -322,11 +373,16 @@ class EntityNormalizer:
def normalize_text(self, text: str) -> str:
return technical_normalizer.normalize_text(text)
def normalize_tool_name(self, tool_name) -> str:
candidate = self.normalize_text(str(tool_name or "")).replace("-", "_").strip()
candidate = re.sub(r"\s+", "_", candidate)
return self._TOOL_NAME_ALIASES.get(candidate, candidate)
def normalize_tool_arguments(self, tool_name: str, arguments) -> dict:
if not isinstance(arguments, dict):
return {}
normalized_tool_name = str(tool_name or "").strip()
normalized_tool_name = self.normalize_tool_name(tool_name)
aliases = self._TOOL_ARGUMENT_ALIASES.get(normalized_tool_name, {})
normalized_arguments: dict = {}
for raw_key, value in arguments.items():
@ -365,6 +421,9 @@ class EntityNormalizer:
if normalized_tool_name == "editar_data_revisao":
return self.normalize_review_management_fields(normalized_arguments)
if normalized_tool_name == "agendar_revisao":
return self.normalize_review_fields(normalized_arguments)
return normalized_arguments
def normalize_plate(self, value) -> str | None:

@ -192,6 +192,9 @@ class MessagePlanner:
"- 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"
"- Se o usuario quiser listar os pedidos dele, use intent='order_list', domain='sales', action='call_tool' e tool_name='listar_pedidos'.\n"
"- 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 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"

@ -42,6 +42,9 @@ LOW_VALUE_RESPONSES = {
DETERMINISTIC_RESPONSE_TOOLS = {
"cancelar_pedido",
"listar_pedidos",
"listar_agendamentos_revisao",
"cancelar_agendamento_revisao",
"editar_data_revisao",
"limpar_contexto_conversa",
"continuar_proximo_pedido",
"descartar_pedidos_pendentes",

@ -1052,6 +1052,10 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
if has_open_review_draft:
return True
active_domain = str(((self._get_user_context(user_id) or {}) if user_id is not None else {}).get("active_domain") or "").strip().lower()
if active_domain == "review":
return True
decision = turn_decision or {}
decision_intent = str(decision.get("intent") or "").strip().lower()
if decision_intent != "review_schedule":

@ -174,6 +174,42 @@ def try_parse_review_absolute_datetime(text: str) -> datetime | None:
return try_parse_datetime_with_formats(normalized, day_first_formats + year_first_formats)
def extract_review_absolute_datetime_text(text: str) -> str | None:
candidate = str(text or "").strip()
if not candidate:
return None
normalized = normalize_datetime_connector(candidate)
patterns = (
r"(?P<value>(?P<day>\d{1,2})[/-](?P<month>\d{1,2})[/-](?P<year>\d{4})\s+(?P<hour>\d{1,2}):(?P<minute>\d{2})(?::(?P<second>\d{2}))?(?:\s*(?P<tz>Z|[+-]\d{2}:\d{2}))?)",
r"(?P<value>(?P<year>\d{4})[/-](?P<month>\d{1,2})[/-](?P<day>\d{1,2})\s+(?P<hour>\d{1,2}):(?P<minute>\d{2})(?::(?P<second>\d{2}))?(?:\s*(?P<tz>Z|[+-]\d{2}:\d{2}))?)",
)
for pattern in patterns:
match = re.search(pattern, normalized)
if not match:
continue
extracted = str(match.group("value") or "").strip()
if try_parse_review_absolute_datetime(extracted) is None:
continue
parts = match.groupdict()
year = int(parts["year"])
month = int(parts["month"])
day = int(parts["day"])
hour = int(parts["hour"])
minute = int(parts["minute"])
second = parts.get("second")
tz = parts.get("tz")
formatted = f"{day:02d}/{month:02d}/{year:04d} {hour:02d}:{minute:02d}"
if second:
formatted += f":{int(second):02d}"
if tz:
formatted += f" {tz}"
return formatted
return None
def strip_token_edges(token: str) -> str:
cleaned = str(token or "").strip()
edge_chars = "[](){}<>,.;:!?\"'`"
@ -197,6 +233,10 @@ def extract_hhmm_from_text(text: str) -> str | None:
minute = int(parts[1])
if 0 <= hour <= 23 and 0 <= minute <= 59:
return f"{hour:02d}:{minute:02d}"
hour_only_match = re.search(r"(?<!\d)([01]?\d|2[0-3])\s*(?:h|hora|horas)\b", cleaned, flags=re.IGNORECASE)
if hour_only_match:
hour = int(hour_only_match.group(1))
return f"{hour:02d}:00"
return None
@ -209,6 +249,10 @@ def normalize_review_datetime_text(value, now_provider=None) -> str | None:
if absolute_dt is not None:
return text
embedded_absolute = extract_review_absolute_datetime_text(text)
if embedded_absolute is not None:
return embedded_absolute
normalized = normalize_text(text)
day_offset = None
if "amanha" in normalized:
@ -216,11 +260,11 @@ def normalize_review_datetime_text(value, now_provider=None) -> str | None:
elif "hoje" in normalized:
day_offset = 0
if day_offset is None:
return text
return None
time_text = extract_hhmm_from_text(normalized)
if not time_text:
return text
return None
hour_text, minute_text = time_text.split(":")
current_datetime = now_provider() if callable(now_provider) else datetime.now()

@ -1,6 +1,7 @@
import inspect
from typing import Callable, Dict, List
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.models.tool_model import ToolDefinition
@ -75,7 +76,14 @@ class ToolRegistry:
tool = next((t for t in self._tools if t.name == name), None)
if not tool:
raise Exception(f"Tool {name} nao encontrada.")
raise HTTPException(
status_code=400,
detail={
"code": "tool_not_found",
"message": f"Tool {name} nao encontrada.",
"retryable": False,
},
)
call_args = dict(arguments or {})
if user_id is not None and "user_id" in inspect.signature(tool.handler).parameters:

@ -78,6 +78,21 @@ class FakeRegistry:
{"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 48500.0},
{"id": 2, "modelo": "Toyota Yaris 2020", "categoria": "hatch", "preco": 49900.0},
]
if tool_name == "listar_pedidos":
return [
{
"numero_pedido": "PED-TESTE-001",
"modelo_veiculo": "Fiat Argo 2020",
"valor_veiculo": 61857.0,
"status": "Ativo",
},
{
"numero_pedido": "PED-TESTE-002",
"modelo_veiculo": "Toyota Corolla 2020",
"valor_veiculo": 58476.0,
"status": "Cancelado",
},
]
if tool_name == "realizar_pedido":
vehicle_map = {
1: ("Honda Civic 2021", 51524.0),
@ -92,6 +107,36 @@ class FakeRegistry:
"modelo_veiculo": modelo_veiculo,
"valor_veiculo": valor_veiculo,
}
if tool_name == "agendar_revisao":
return {
"protocolo": "REV-TESTE-123",
"placa": arguments["placa"],
"data_hora": arguments["data_hora"],
"valor_revisao": 840.60,
}
if tool_name == "listar_agendamentos_revisao":
return [
{
"protocolo": "REV-TESTE-001",
"placa": "ABC1234",
"data_hora": "13/03/2026 16:00",
"status": "Agendado",
}
]
if tool_name == "cancelar_agendamento_revisao":
return {
"protocolo": arguments["protocolo"],
"placa": "ABC1269",
"data_hora": "13/03/2026 16:00",
"status": "Cancelado",
}
if tool_name == "editar_data_revisao":
return {
"protocolo": arguments["protocolo"],
"placa": "ABC1269",
"data_hora": arguments["nova_data_hora"],
"status": "Remarcado",
}
return {
"numero_pedido": arguments["numero_pedido"],
"status": "Cancelado",
@ -155,6 +200,14 @@ class OrderFlowHarness(OrderFlowMixin):
f"Veiculo: {tool_result['modelo_veiculo']}\n"
f"Valor: R$ {tool_result['valor_veiculo']:.2f}"
)
if tool_name == "listar_pedidos":
lines = [f"Encontrei {len(tool_result)} pedido(s):"]
for idx, item in enumerate(tool_result, start=1):
lines.append(
f"{idx}. {item['numero_pedido']} | {item['modelo_veiculo']} | "
f"{item['status']} | R$ {item['valor_veiculo']:.2f}"
)
return "\n".join(lines)
return (
f"Pedido {tool_result['numero_pedido']} atualizado.\n"
f"Status: {tool_result['status']}\n"
@ -179,6 +232,7 @@ class ReviewFlowHarness(ReviewFlowMixin):
self.tool_executor = registry
self.normalizer = EntityNormalizer()
self.captured_suggestions = []
self.logged_events = []
def _normalize_intents(self, data) -> dict:
return self.normalizer.normalize_intents(data)
@ -192,10 +246,16 @@ class ReviewFlowHarness(ReviewFlowMixin):
def _normalize_text(self, text: str) -> str:
return self.normalizer.normalize_text(text)
def _normalize_review_datetime_text(self, value) -> str | None:
return self.normalizer.normalize_review_datetime_text(value)
def _http_exception_detail(self, exc) -> str:
detail = exc.detail if isinstance(exc.detail, dict) else {}
return str(detail.get("message") or exc)
def _get_user_context(self, user_id: int | None):
return self.state.get_user_context(user_id)
def _fallback_format_tool_result(self, tool_name: str, tool_result) -> str:
return f"{tool_name}:{tool_result}"
@ -216,6 +276,15 @@ class ReviewFlowHarness(ReviewFlowMixin):
def _try_prefill_review_fields_from_memory(self, user_id: int | None, payload: dict) -> None:
return None
def _log_turn_event(self, event: str, **payload) -> None:
self.logged_events.append((event, payload))
def _reset_pending_review_states(self, user_id: int | None) -> None:
self.state.pop_entry("pending_review_drafts", user_id)
self.state.pop_entry("pending_review_management_drafts", user_id)
self.state.pop_entry("pending_review_confirmations", user_id)
self.state.pop_entry("pending_review_reuse_confirmations", user_id)
class ConversationAdjustmentsTests(unittest.TestCase):
def test_telegram_satellite_requires_redis_in_production(self):
@ -263,6 +332,35 @@ class ConversationAdjustmentsTests(unittest.TestCase):
self.assertEqual(parsed, datetime(2026, 3, 10, 9, 0))
def test_normalize_review_datetime_extracts_datetime_from_long_review_sentence(self):
normalizer = EntityNormalizer()
self.assertEqual(
normalizer.normalize_review_datetime_text(
"para ABC1234 em 28/03/2026 as 8:00, Corolla, 2020, 30000 km, ja fiz revisao"
),
"28/03/2026 08:00",
)
def test_normalize_review_fields_discards_invalid_datetime_noise(self):
normalizer = EntityNormalizer()
self.assertEqual(
normalizer.normalize_review_fields({"data_hora": "quero agendar uma revisao qualquer"}),
{},
)
def test_reset_message_variants_strip_previous_context_prefix(self):
state = FakeState()
policy = ConversationPolicy(service=FakeService(state))
message = "Esqueça as operações anteriores, agora quero agendar revisão para ABC1234"
cleaned = policy.remove_order_selection_reset_prefix(message)
self.assertTrue(policy.is_order_selection_reset_message(message))
self.assertEqual(cleaned, "quero agendar revisão para ABC1234")
class CancelOrderFlowTests(unittest.IsolatedAsyncioTestCase):
async def test_cancel_order_flow_accepts_turn_decision_without_legacy_intents(self):
state = FakeState()
@ -277,7 +375,7 @@ class CancelOrderFlowTests(unittest.IsolatedAsyncioTestCase):
turn_decision={"intent": "order_cancel", "domain": "sales", "action": "collect_order_cancel"},
)
self.assertIn("o motivo do cancelamento", response)
self.assertEqual(response, "Encontrei o pedido informado. Qual o motivo do cancelamento?")
self.assertIsNotNone(state.get_entry("pending_cancel_order_drafts", 42))
async def test_cancel_order_flow_consumes_free_text_reason(self):
@ -310,6 +408,38 @@ class CancelOrderFlowTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("Status: Cancelado", response)
self.assertIsNone(state.get_entry("pending_cancel_order_drafts", 42))
async def test_cancel_order_flow_consumes_free_text_reason_even_when_model_repeats_order_cancel_intent(self):
state = FakeState(
entries={
"pending_cancel_order_drafts": {
42: {
"payload": {"numero_pedido": "PED-20260305120000-ABC123"},
"expires_at": datetime.utcnow() + timedelta(minutes=30),
}
}
}
)
registry = FakeRegistry()
flow = OrderFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_cancel_order(
message="Eu desisti dessa compra",
user_id=42,
extracted_fields={},
intents={},
turn_decision={"intent": "order_cancel", "domain": "sales", "action": "answer_user"},
)
self.assertEqual(len(registry.calls), 1)
tool_name, arguments, tool_user_id = registry.calls[0]
self.assertEqual(tool_name, "cancelar_pedido")
self.assertEqual(tool_user_id, 42)
self.assertEqual(arguments["numero_pedido"], "PED-20260305120000-ABC123")
self.assertEqual(arguments["motivo"], "Eu desisti dessa compra")
self.assertIn("Pedido PED-20260305120000-ABC123 atualizado.", response)
self.assertIn("Status: Cancelado", response)
self.assertIsNone(state.get_entry("pending_cancel_order_drafts", 42))
async def test_cancel_order_flow_still_requests_reason_when_message_is_too_short(self):
state = FakeState(
entries={
@ -361,6 +491,46 @@ class CancelOrderFlowTests(unittest.IsolatedAsyncioTestCase):
class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
async def test_order_listing_preserves_open_order_draft(self):
state = FakeState(
entries={
"pending_order_drafts": {
10: {
"payload": {"cpf": "12345678909"},
"expires_at": datetime.utcnow() + timedelta(minutes=30),
}
}
}
)
registry = FakeRegistry()
flow = OrderFlowHarness(state=state, registry=registry)
response = await flow._try_handle_order_listing(
message="Liste os meus pedidos",
user_id=10,
intents={},
turn_decision={"intent": "order_list", "domain": "sales", "action": "call_tool"},
)
self.assertEqual(registry.calls[0][0], "listar_pedidos")
self.assertIn("Encontrei 2 pedido(s):", response)
self.assertIsNotNone(state.get_entry("pending_order_drafts", 10))
async def test_order_listing_ignores_review_appointment_listing_message(self):
state = FakeState()
registry = FakeRegistry()
flow = OrderFlowHarness(state=state, registry=registry)
response = await flow._try_handle_order_listing(
message="liste para mim os meus agendamentos de revisao",
user_id=10,
intents={},
turn_decision={"intent": "order_list", "domain": "sales", "action": "call_tool"},
)
self.assertIsNone(response)
self.assertEqual(registry.calls, [])
async def test_order_flow_auto_lists_stock_on_first_purchase_message_when_budget_exists(self):
state = FakeState(
contexts={
@ -819,6 +989,224 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
async def test_review_flow_extracts_relative_datetime_from_followup_message(self):
state = FakeState(
entries={
"pending_review_drafts": {
21: {
"payload": {"placa": "ABC1269"},
"expires_at": datetime.utcnow() + timedelta(minutes=30),
}
}
}
)
registry = FakeRegistry()
flow = ReviewFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_schedule_review(
message="Eu gostaria de marcar amanha as 16 horas",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "review_schedule", "domain": "review", "action": "answer_user"},
)
draft = state.get_entry("pending_review_drafts", 21)
self.assertIsNotNone(draft)
self.assertIn("data_hora", draft["payload"])
self.assertEqual(draft["payload"]["data_hora"][-5:], "16:00")
self.assertIn("o modelo do veiculo", response)
self.assertTrue(any(payload.get("review_flow_source") == "draft" for _, payload in flow.logged_events))
async def test_review_flow_extracts_model_year_km_and_review_history_from_free_text(self):
state = FakeState(
entries={
"pending_review_drafts": {
21: {
"payload": {"placa": "ABC1269", "data_hora": "13/03/2026 16:00"},
"expires_at": datetime.utcnow() + timedelta(minutes=30),
}
}
}
)
registry = FakeRegistry()
flow = ReviewFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_schedule_review(
message="O modelo do meu carro e um Onix e ele e 2021, 30000 km, nunca fiz revisao",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "review_schedule", "domain": "review", "action": "answer_user"},
)
self.assertIsNone(state.get_entry("pending_review_drafts", 21))
self.assertEqual(registry.calls[0][0], "agendar_revisao")
_, arguments, tool_user_id = registry.calls[0]
self.assertEqual(tool_user_id, 21)
self.assertEqual(arguments.get("modelo"), "Um Onix")
self.assertEqual(arguments.get("ano"), 2021)
self.assertEqual(arguments.get("km"), 30000)
self.assertFalse(arguments.get("revisao_previa_concessionaria"))
self.assertIn("REV-TESTE-123", response)
async def test_review_flow_keeps_plate_and_datetime_across_incremental_messages(self):
state = FakeState()
registry = FakeRegistry()
flow = ReviewFlowHarness(state=state, registry=registry)
await flow._try_collect_and_schedule_review(
message="gostaria de marcar uma nova revisao agora",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "review_schedule", "domain": "review", "action": "ask_missing_fields"},
)
await flow._try_collect_and_schedule_review(
message="placa ABC1269",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "review_schedule", "domain": "review", "action": "answer_user"},
)
await flow._try_collect_and_schedule_review(
message="Eu gostaria de marcar amanha as 16 horas",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "review_schedule", "domain": "review", "action": "answer_user"},
)
await flow._try_collect_and_schedule_review(
message="O modelo do meu carro e um Onix e ele e 2021",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "review_schedule", "domain": "review", "action": "answer_user"},
)
response = await flow._try_collect_and_schedule_review(
message="30000 km, nunca fiz revisao",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "review_schedule", "domain": "review", "action": "answer_user"},
)
self.assertIsNone(state.get_entry("pending_review_drafts", 21))
self.assertEqual(registry.calls[0][0], "agendar_revisao")
_, arguments, tool_user_id = registry.calls[0]
self.assertEqual(tool_user_id, 21)
self.assertEqual(arguments.get("placa"), "ABC1269")
self.assertEqual(arguments.get("data_hora"), "13/03/2026 16:00")
self.assertEqual(arguments.get("modelo"), "Um Onix")
self.assertEqual(arguments.get("ano"), 2021)
self.assertEqual(arguments.get("km"), 30000)
self.assertFalse(arguments.get("revisao_previa_concessionaria"))
self.assertIn("REV-TESTE-123", response)
async def test_review_flow_bootstraps_from_active_review_context_when_draft_is_missing(self):
state = FakeState(
contexts={
21: {
"active_domain": "review",
"generic_memory": {},
"shared_memory": {},
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
}
}
)
registry = FakeRegistry()
flow = ReviewFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_schedule_review(
message="placa ABC1269",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "general", "domain": "general", "action": "answer_user"},
)
draft = state.get_entry("pending_review_drafts", 21)
self.assertIsNotNone(draft)
self.assertEqual(draft["payload"]["placa"], "ABC1269")
self.assertIn("a data e hora desejada para a revisao", response)
self.assertTrue(
any(payload.get("review_flow_source") == "active_domain_fallback" for _, payload in flow.logged_events)
)
async def test_review_flow_offers_reuse_of_last_vehicle_package(self):
state = FakeState(
entries={
"last_review_packages": {
21: {
"payload": {
"placa": "ABC1234",
"modelo": "Corolla",
"ano": 2020,
"km": 30000,
"revisao_previa_concessionaria": True,
},
"expires_at": datetime.utcnow() + timedelta(minutes=30),
}
}
}
)
registry = FakeRegistry()
flow = ReviewFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_schedule_review(
message="gostaria de agendar uma nova revisao agora",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "review_schedule", "domain": "review", "action": "collect_review_schedule"},
)
self.assertIn("Posso reutilizar os dados do ultimo veiculo", response)
self.assertIn("Corolla", response)
self.assertIn("ABC1234", response)
self.assertIsNotNone(state.get_entry("pending_review_reuse_confirmations", 21))
self.assertTrue(
any(payload.get("review_flow_source") == "last_review_package" for _, payload in flow.logged_events)
)
async def test_review_flow_rejects_reuse_and_accepts_new_vehicle_in_same_message(self):
state = FakeState(
entries={
"pending_review_reuse_confirmations": {
21: {
"payload": {
"placa": "ABC1234",
"modelo": "Corolla",
"ano": 2020,
"km": 30000,
"revisao_previa_concessionaria": True,
},
"expires_at": datetime.utcnow() + timedelta(minutes=30),
}
}
}
)
registry = FakeRegistry()
flow = ReviewFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_schedule_review(
message="nao, agora e outro veiculo, placa ABC1269",
user_id=21,
extracted_fields={"placa": "ABC1269"},
intents={},
turn_decision={"intent": "review_schedule", "domain": "review", "action": "collect_review_schedule"},
)
draft = state.get_entry("pending_review_drafts", 21)
self.assertIsNone(state.get_entry("pending_review_reuse_confirmations", 21))
self.assertIsNotNone(draft)
self.assertEqual(draft["payload"].get("placa"), "ABC1269")
self.assertIn("a data e hora desejada para a revisao", response)
async def test_review_flow_keeps_draft_and_clears_data_hora_on_retryable_error(self):
state = FakeState(
entries={
@ -864,6 +1252,184 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(draft["payload"].get("placa"), "ABC1234")
self.assertNotIn("data_hora", draft["payload"])
async def test_review_management_infers_cancel_intent_from_protocol_message(self):
state = FakeState()
registry = FakeRegistry()
flow = ReviewFlowHarness(state=state, registry=registry)
response = await flow._try_handle_review_management(
message="eu gostaria de cancelar o meu agendamento REV-20260313-F754AF27",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "review_schedule", "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.assertIn("cancelar_agendamento_revisao", response)
self.assertIn("REV-20260313-F754AF27", response)
async def test_review_management_infers_listing_intent_from_agendamentos_message(self):
state = FakeState()
registry = FakeRegistry()
flow = ReviewFlowHarness(state=state, registry=registry)
response = await flow._try_handle_review_management(
message="liste para mim os meus agendamentos de revisao",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "general", "domain": "general", "action": "answer_user"},
)
self.assertEqual(registry.calls[0][0], "listar_agendamentos_revisao")
self.assertIn("listar_agendamentos_revisao", response)
async def test_review_schedule_clears_open_management_draft(self):
state = FakeState(
entries={
"pending_review_management_drafts": {
21: {
"action": "reschedule",
"payload": {"protocolo": "REV-20260313-F754AF27"},
"expires_at": datetime.utcnow() + timedelta(minutes=30),
}
}
}
)
registry = FakeRegistry()
flow = ReviewFlowHarness(state=state, registry=registry)
response = await flow._try_handle_review_management(
message="quero agendar uma revisao",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "review_schedule", "domain": "review", "action": "answer_user"},
)
self.assertIsNone(response)
self.assertIsNone(state.get_entry("pending_review_management_drafts", 21))
async def test_review_management_does_not_override_open_schedule_draft_without_protocol(self):
state = FakeState(
entries={
"pending_review_drafts": {
21: {
"payload": {
"placa": "ABC1234",
"modelo": "Corolla",
"ano": 2020,
"km": 30000,
"revisao_previa_concessionaria": True,
},
"expires_at": datetime.utcnow() + timedelta(minutes=30),
}
}
}
)
registry = FakeRegistry()
flow = ReviewFlowHarness(state=state, registry=registry)
response = await flow._try_handle_review_management(
message="pode ser hoje as 17:30",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "review_reschedule", "domain": "review", "action": "answer_user"},
)
self.assertIsNone(response)
self.assertIsNone(state.get_entry("pending_review_management_drafts", 21))
async def test_review_schedule_flow_ignores_management_message_with_protocol(self):
state = FakeState(
contexts={
21: {
"active_domain": "review",
"generic_memory": {},
"shared_memory": {},
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
}
}
)
registry = FakeRegistry()
flow = ReviewFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_schedule_review(
message="eu gostaria de cancelar o meu agendamento REV-20260313-F754AF27",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "review_schedule", "domain": "review", "action": "answer_user"},
)
self.assertIsNone(response)
self.assertEqual(registry.calls, [])
async def test_review_flow_does_not_bootstrap_sales_message_from_active_review_context(self):
state = FakeState(
contexts={
21: {
"active_domain": "review",
"generic_memory": {},
"shared_memory": {},
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
}
}
)
registry = FakeRegistry()
flow = ReviewFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_schedule_review(
message="quero comprar um carro de ate 70 mil",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "general", "domain": "general", "action": "answer_user"},
)
self.assertIsNone(response)
self.assertIsNone(state.get_entry("pending_review_drafts", 21))
class ContextSwitchPolicyTests(unittest.TestCase):
def test_handle_context_switch_drops_stale_pending_switch_when_user_starts_other_domain(self):
state = FakeState(
contexts={
9: {
"pending_switch": {
"target_domain": "sales",
"expires_at": datetime.utcnow() + timedelta(minutes=15),
},
"active_domain": "general",
"generic_memory": {},
"pending_order_selection": None,
}
}
)
service = FakeService(state)
policy = ConversationPolicy(service=service)
response = policy.handle_context_switch(
message="quero agendar revisao",
user_id=9,
target_domain_hint="review",
turn_decision={"domain": "review", "intent": "review_schedule", "action": "collect_review_schedule"},
)
self.assertIsNone(response)
self.assertIsNone(service._get_user_context(9).get("pending_switch"))
if __name__ == "__main__":
unittest.main()

@ -245,6 +245,190 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(decision["missing_fields"], [])
self.assertIsNone(decision["response_to_user"])
def test_coerce_turn_decision_normalizes_cancel_order_tool_argument_aliases(self):
normalizer = EntityNormalizer()
decision = normalizer.coerce_turn_decision(
{
"intent": "order_cancel",
"domain": "sales",
"action": "call_tool",
"tool_name": "cancelar_pedido",
"tool_arguments": {
"order_id": "PED-20260310113756-DC1540",
"reason": "desisti da compra",
},
"entities": {
"generic_memory": {},
"review_fields": {},
"review_management_fields": {},
"order_fields": {},
"cancel_order_fields": {},
},
"missing_fields": [],
"response_to_user": None,
}
)
self.assertEqual(decision["tool_arguments"]["numero_pedido"], "PED-20260310113756-DC1540")
self.assertEqual(decision["tool_arguments"]["motivo"], "desisti da compra")
def test_coerce_turn_decision_normalizes_review_tool_name_alias(self):
normalizer = EntityNormalizer()
decision = normalizer.coerce_turn_decision(
{
"intent": "review_schedule",
"domain": "review",
"action": "call_tool",
"tool_name": "marcar_revisao",
"tool_arguments": {
"placa": "ABC1234",
"data_hora": "19/03/2026 09:00",
"modelo": "Corolla",
"ano": 2020,
"km": 30000,
"revisao_previa_concessionaria": True,
},
"entities": {
"generic_memory": {},
"review_fields": {},
"review_management_fields": {},
"order_fields": {},
"cancel_order_fields": {},
},
"missing_fields": [],
"response_to_user": None,
}
)
self.assertEqual(decision["tool_name"], "agendar_revisao")
self.assertEqual(decision["tool_arguments"]["placa"], "ABC1234")
def test_coerce_turn_decision_normalizes_review_schedule_tool_argument_aliases(self):
normalizer = EntityNormalizer()
decision = normalizer.coerce_turn_decision(
{
"intent": "review_schedule",
"domain": "review",
"action": "call_tool",
"tool_name": "agendar_revisao",
"tool_arguments": {
"placa_veiculo": "ABC1234",
"data": "20/03/2026 09:00",
"modelo_veiculo": "Corolla",
"ano_veiculo": 2020,
"quilometragem": 30000,
"revisao_previa": True,
},
"entities": {
"generic_memory": {},
"review_fields": {},
"review_management_fields": {},
"order_fields": {},
"cancel_order_fields": {},
},
"missing_fields": [],
"response_to_user": None,
}
)
self.assertEqual(decision["tool_arguments"]["placa"], "ABC1234")
self.assertEqual(decision["tool_arguments"]["data_hora"], "20/03/2026 09:00")
self.assertEqual(decision["tool_arguments"]["modelo"], "Corolla")
self.assertEqual(decision["tool_arguments"]["ano"], 2020)
self.assertEqual(decision["tool_arguments"]["km"], 30000)
self.assertTrue(decision["tool_arguments"]["revisao_previa_concessionaria"])
def test_coerce_turn_decision_downgrades_incomplete_review_schedule_tool_call_to_collection(self):
normalizer = EntityNormalizer()
decision = normalizer.coerce_turn_decision(
{
"intent": "review_schedule",
"domain": "review",
"action": "call_tool",
"tool_name": "agendar_revisao",
"tool_arguments": {
"placa_veiculo": "ABC1234",
"modelo_veiculo": "Corolla",
"ano_veiculo": 2020,
},
"entities": {
"generic_memory": {},
"review_fields": {},
"review_management_fields": {},
"order_fields": {},
"cancel_order_fields": {},
},
"missing_fields": [],
"response_to_user": None,
}
)
self.assertEqual(decision["action"], "collect_review_schedule")
self.assertIsNone(decision["tool_name"])
self.assertEqual(decision["tool_arguments"], {})
self.assertEqual(decision["entities"]["review_fields"]["placa"], "ABC1234")
self.assertEqual(decision["entities"]["review_fields"]["modelo"], "Corolla")
self.assertEqual(decision["entities"]["review_fields"]["ano"], 2020)
def test_coerce_turn_decision_normalizes_review_management_tool_name_alias(self):
normalizer = EntityNormalizer()
decision = normalizer.coerce_turn_decision(
{
"intent": "review_cancel",
"domain": "review",
"action": "call_tool",
"tool_name": "cancelar_agendamento",
"tool_arguments": {
"protocolo": "REV-20260313-F754AF27",
},
"entities": {
"generic_memory": {},
"review_fields": {},
"review_management_fields": {},
"order_fields": {},
"cancel_order_fields": {},
},
"missing_fields": [],
"response_to_user": None,
}
)
self.assertEqual(decision["tool_name"], "cancelar_agendamento_revisao")
def test_coerce_turn_decision_downgrades_incomplete_cancel_order_tool_call_to_collection(self):
normalizer = EntityNormalizer()
decision = normalizer.coerce_turn_decision(
{
"intent": "order_cancel",
"domain": "sales",
"action": "call_tool",
"tool_name": "cancelar_pedido",
"tool_arguments": {
"order_id": "PED-20260310124202-5EF4E9",
},
"entities": {
"generic_memory": {},
"review_fields": {},
"review_management_fields": {},
"order_fields": {},
"cancel_order_fields": {},
},
"missing_fields": [],
"response_to_user": None,
}
)
self.assertEqual(decision["action"], "collect_order_cancel")
self.assertIsNone(decision["tool_name"])
self.assertEqual(decision["tool_arguments"], {})
self.assertEqual(decision["entities"]["cancel_order_fields"]["numero_pedido"], "PED-20260310124202-5EF4E9")
def test_coerce_turn_decision_rejects_missing_fields_without_response_payload(self):
normalizer = EntityNormalizer()
@ -407,6 +591,42 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response, "realizar_pedido:PED-1")
self.assertEqual(service.llm.calls, 0)
async def test_turn_decision_cancel_order_uses_deterministic_response_without_result_llm(self):
service = OrquestradorService.__new__(OrquestradorService)
service.tool_executor = FakeToolExecutor(result={"numero_pedido": "PED-1", "status": "Cancelado", "motivo": "desisti"})
service.llm = FakeLLM([])
service._capture_review_confirmation_suggestion = lambda **kwargs: None
service._capture_tool_result_context = lambda **kwargs: None
service._should_use_deterministic_response = lambda tool_name: tool_name == "cancelar_pedido"
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
async def finish(response: str, queue_notice: str | None = None) -> str:
return response if not queue_notice else f"{queue_notice}\n{response}"
response = await service._try_execute_business_tool_from_turn_decision(
message="cancelar pedido",
user_id=7,
turn_decision={
"action": "call_tool",
"tool_name": "cancelar_pedido",
"tool_arguments": {"numero_pedido": "PED-1", "motivo": "desisti"},
},
queue_notice=None,
finish=finish,
)
self.assertEqual(response, "Pedido PED-1 atualizado.\nStatus: Cancelado")
self.assertEqual(service.llm.calls, 0)
async def test_empty_stock_search_suggests_nearby_options(self):
service = OrquestradorService.__new__(OrquestradorService)
service.normalizer = EntityNormalizer()
@ -577,6 +797,252 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("Encontrei 2 veiculo(s):", response)
def test_should_prioritize_review_flow_when_review_draft_is_open(self):
state = FakeState(
entries={
"pending_review_drafts": {
1: {
"payload": {"placa": "ABC1269"},
"expires_at": datetime.utcnow() + timedelta(minutes=15),
}
}
}
)
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
prioritized = service._should_prioritize_review_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,
)
self.assertTrue(prioritized)
def test_should_prioritize_review_flow_when_active_domain_is_review(self):
state = FakeState(
contexts={
1: {
"active_domain": "review",
"generic_memory": {},
"shared_memory": {},
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
"expires_at": datetime.utcnow() + timedelta(minutes=15),
}
}
)
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
prioritized = service._should_prioritize_review_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,
)
self.assertTrue(prioritized)
async def test_handle_message_prioritizes_review_flow_over_model_answer_for_followup(self):
state = FakeState(
entries={
"pending_review_drafts": {
1: {
"payload": {"placa": "ABC1269"},
"expires_at": datetime.utcnow() + timedelta(minutes=15),
}
}
},
contexts={
1: {
"active_domain": "review",
"generic_memory": {"placa": "ABC1269"},
"shared_memory": {"placa": "ABC1269"},
"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._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": "Para que tipo de compromisso voce gostaria de marcar amanha as 16 horas?",
}
service._extract_turn_decision_with_llm = fake_extract_turn_decision
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_message_plan(message: str, user_id: int | None):
return {
"orders": [
{
"domain": "review",
"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": {"data_hora": "13/03/2026 16:00"},
"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 "Para agendar sua revisao, preciso dos dados abaixo:\n- o modelo do veiculo"
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 None
service._try_collect_and_create_order = fake_try_collect_and_create_order
response = await service.handle_message(
"Eu gostaria de marcar amanha as 16 horas",
user_id=1,
)
self.assertIn("o modelo do veiculo", response)
def test_should_prioritize_order_flow_when_cancel_draft_is_open(self):
state = FakeState(
entries={
"pending_cancel_order_drafts": {
1: {
"payload": {"numero_pedido": "PED-202603101204814-6ED33A"},
"expires_at": datetime.utcnow() + timedelta(minutes=15),
}
}
}
)
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
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,
)
self.assertTrue(prioritized)
async def test_pending_order_selection_prefers_turn_decision_domain(self):
state = FakeState(
contexts={

Loading…
Cancel
Save