From 95f3ed2f6ba767cf39794b2f5a8432102c12b563 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vitor=20Hugo=20Belorio=20Sim=C3=A3o?= Date: Thu, 12 Mar 2026 14:59:10 -0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(review):=20blindar=20agendam?= =?UTF-8?q?ento=20e=20gestao=20de=20revisoes=20no=20fluxo=20estruturado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/services/flows/order_flow.py | 12 +- app/services/flows/review_flow.py | 188 +++++- .../orchestration/entity_normalizer.py | 63 +- app/services/orchestration/message_planner.py | 3 + .../orchestration/orchestrator_config.py | 3 + .../orchestration/orquestrador_service.py | 4 + .../orchestration/technical_normalizer.py | 48 +- app/services/tools/tool_registry.py | 10 +- tests/test_conversation_adjustments.py | 568 +++++++++++++++++- tests/test_turn_decision_contract.py | 466 ++++++++++++++ 10 files changed, 1349 insertions(+), 16 deletions(-) diff --git a/app/services/flows/order_flow.py b/app/services/flows/order_flow.py index 3e80009..f223931 100644 --- a/app/services/flows/order_flow.py +++ b/app/services/flows/order_flow.py @@ -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) diff --git a/app/services/flows/review_flow.py b/app/services/flows/review_flow.py index 1af44d2..5e39e38 100644 --- a/app/services/flows/review_flow.py +++ b/app/services/flows/review_flow.py @@ -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"(? 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) diff --git a/app/services/orchestration/entity_normalizer.py b/app/services/orchestration/entity_normalizer.py index 8a176b0..a80bafd 100644 --- a/app/services/orchestration/entity_normalizer.py +++ b/app/services/orchestration/entity_normalizer.py @@ -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: diff --git a/app/services/orchestration/message_planner.py b/app/services/orchestration/message_planner.py index 1d39d20..3318d62 100644 --- a/app/services/orchestration/message_planner.py +++ b/app/services/orchestration/message_planner.py @@ -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" diff --git a/app/services/orchestration/orchestrator_config.py b/app/services/orchestration/orchestrator_config.py index 0c50c92..f096c65 100644 --- a/app/services/orchestration/orchestrator_config.py +++ b/app/services/orchestration/orchestrator_config.py @@ -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", diff --git a/app/services/orchestration/orquestrador_service.py b/app/services/orchestration/orquestrador_service.py index 365e515..a2711cd 100644 --- a/app/services/orchestration/orquestrador_service.py +++ b/app/services/orchestration/orquestrador_service.py @@ -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": diff --git a/app/services/orchestration/technical_normalizer.py b/app/services/orchestration/technical_normalizer.py index 5c091f8..84007b7 100644 --- a/app/services/orchestration/technical_normalizer.py +++ b/app/services/orchestration/technical_normalizer.py @@ -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(?P\d{1,2})[/-](?P\d{1,2})[/-](?P\d{4})\s+(?P\d{1,2}):(?P\d{2})(?::(?P\d{2}))?(?:\s*(?PZ|[+-]\d{2}:\d{2}))?)", + r"(?P(?P\d{4})[/-](?P\d{1,2})[/-](?P\d{1,2})\s+(?P\d{1,2}):(?P\d{2})(?::(?P\d{2}))?(?:\s*(?PZ|[+-]\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"(? 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() diff --git a/app/services/tools/tool_registry.py b/app/services/tools/tool_registry.py index 85cf725..4a51d21 100644 --- a/app/services/tools/tool_registry.py +++ b/app/services/tools/tool_registry.py @@ -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: diff --git a/tests/test_conversation_adjustments.py b/tests/test_conversation_adjustments.py index 882c44f..66e63c9 100644 --- a/tests/test_conversation_adjustments.py +++ b/tests/test_conversation_adjustments.py @@ -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() diff --git a/tests/test_turn_decision_contract.py b/tests/test_turn_decision_contract.py index a381263..d79ebea 100644 --- a/tests/test_turn_decision_contract.py +++ b/tests/test_turn_decision_contract.py @@ -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={