diff --git a/app/integrations/telegram_satellite_service.py b/app/integrations/telegram_satellite_service.py index 613c861..532e674 100644 --- a/app/integrations/telegram_satellite_service.py +++ b/app/integrations/telegram_satellite_service.py @@ -17,6 +17,59 @@ from app.services.user.user_service import UserService logger = logging.getLogger(__name__) +TELEGRAM_MESSAGE_SAFE_LIMIT = 3800 + + +def _split_telegram_text(text: str, limit: int = TELEGRAM_MESSAGE_SAFE_LIMIT) -> List[str]: + normalized = str(text or "").strip() + if not normalized: + return [""] + if len(normalized) <= limit: + return [normalized] + + chunks: List[str] = [] + current = "" + + def flush_current() -> None: + nonlocal current + if current: + chunks.append(current) + current = "" + + paragraphs = normalized.split("\n\n") + for paragraph in paragraphs: + candidate = paragraph if not current else f"{current}\n\n{paragraph}" + if len(candidate) <= limit: + current = candidate + continue + + flush_current() + if len(paragraph) <= limit: + current = paragraph + continue + + line_buffer = "" + for line in paragraph.split("\n"): + line_candidate = line if not line_buffer else f"{line_buffer}\n{line}" + if len(line_candidate) <= limit: + line_buffer = line_candidate + continue + + if line_buffer: + chunks.append(line_buffer) + line_buffer = "" + + while len(line) > limit: + chunks.append(line[:limit]) + line = line[limit:] + line_buffer = line + + if line_buffer: + current = line_buffer + + flush_current() + return chunks or [normalized[:limit]] + def _ensure_supported_runtime_configuration() -> None: """ @@ -185,14 +238,15 @@ class TelegramSatelliteService: text: str, ) -> None: """Envia mensagem de texto para o chat informado no Telegram.""" - payload = { - "chat_id": chat_id, - "text": text, - } - async with session.post(f"{self.base_url}/sendMessage", json=payload) as response: - data = await response.json() - if not data.get("ok"): - logger.warning("Falha em sendMessage: %s", data) + for chunk in _split_telegram_text(text): + payload = { + "chat_id": chat_id, + "text": chunk, + } + async with session.post(f"{self.base_url}/sendMessage", json=payload) as response: + data = await response.json() + if not data.get("ok"): + logger.warning("Falha em sendMessage: %s", data) async def _process_message(self, text: str, sender: Dict[str, Any], chat_id: int) -> str: """Encaminha mensagem ao orquestrador com usuario identificado e retorna resposta.""" diff --git a/app/services/flows/order_flow.py b/app/services/flows/order_flow.py index 9253ba6..facc964 100644 --- a/app/services/flows/order_flow.py +++ b/app/services/flows/order_flow.py @@ -214,6 +214,18 @@ class OrderFlowMixin: def _is_valid_cpf(self, cpf: str) -> bool: return is_valid_cpf(cpf) + def _extract_order_cpf_attempt(self, message: str) -> str | None: + normalized = self._normalize_text(message).strip() + digits = re.sub(r"\D", "", str(message or "")) + if len(digits) < 3 or len(digits) > 11: + return None + if "cpf" in normalized: + return digits + residue = re.sub(r"[\d\s\.\-_/]", "", str(message or "")) + if not residue: + return digits + return None + def _try_capture_order_cpf_from_message(self, message: str, payload: dict) -> None: if payload.get("cpf"): return @@ -714,6 +726,11 @@ class OrderFlowMixin: return any(term in normalized for term in restart_terms) def _render_missing_order_fields_prompt(self, missing_fields: list[str]) -> str: + if missing_fields == ["vehicle_id"]: + return ( + "Para seguir com o pedido, me diga qual carro voce procura.\n" + "Se preferir, posso listar opcoes por faixa de preco, modelo ou tipo de carro." + ) labels = { "cpf": "o CPF do cliente", "vehicle_id": "qual veiculo do estoque voce quer comprar", @@ -816,7 +833,7 @@ class OrderFlowMixin: try: tool_result = await self.tool_executor.execute( "listar_pedidos", - {"limite": 10}, + {"limite": 50}, user_id=user_id, ) except HTTPException as exc: @@ -898,6 +915,17 @@ class OrderFlowMixin: draft["payload"].update(extracted) self._try_capture_order_cpf_from_message(message=message, payload=draft["payload"]) + cpf_attempt = self._extract_order_cpf_attempt(message) + if cpf_attempt and not draft["payload"].get("cpf") and not self._is_valid_cpf(cpf_attempt): + draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_ORDER_DRAFT_TTL_MINUTES) + self._set_order_flow_entry( + "pending_order_drafts", + user_id, + "order_create", + draft, + active_task="order_create", + ) + return "Para seguir com o pedido, preciso de um CPF valido. Pode me informar novamente?" self._try_capture_order_budget_from_message(user_id=user_id, message=message, payload=draft["payload"]) self._try_prefill_order_cpf_from_memory(user_id=user_id, payload=draft["payload"]) self._try_prefill_order_vehicle_from_context(user_id=user_id, payload=draft["payload"]) @@ -1022,6 +1050,14 @@ class OrderFlowMixin: draft, active_task="order_create", ) + else: + self._pop_order_flow_entry( + "pending_order_drafts", + user_id, + "order_create", + active_task="order_create", + ) + self._reset_order_stock_context(user_id=user_id) return self._http_exception_detail(exc) self._pop_order_flow_entry( "pending_order_drafts", diff --git a/app/services/flows/review_flow.py b/app/services/flows/review_flow.py index 5903b39..3caab96 100644 --- a/app/services/flows/review_flow.py +++ b/app/services/flows/review_flow.py @@ -366,6 +366,26 @@ class ReviewFlowMixin: return "cancel" return None + def _extract_review_cancel_reason_from_message(self, message: str) -> str | None: + raw_message = str(message or "").strip() + if len(raw_message) < 4: + return None + patterns = ( + r"\bporque\b", + r"\bpois\b", + r"\bpor conta de\b", + r"\bmotivo(?: do cancelamento)?\b\s*[:\-]?", + r"\bja que\b", + ) + for pattern in patterns: + match = re.search(pattern, raw_message, flags=re.IGNORECASE) + if not match: + continue + reason = raw_message[match.end():].strip(" ,.;:-") + if len(reason) >= 4: + return reason + return None + def _should_bootstrap_review_from_active_context(self, message: str, payload: dict | None = None) -> bool: normalized_message = self._normalize_text(message).strip() normalized_payload = payload if isinstance(payload, dict) else {} @@ -455,7 +475,7 @@ class ReviewFlowMixin: try: tool_result = await self.tool_executor.execute( "listar_agendamentos_revisao", - {"limite": 20}, + {"limite": 100}, user_id=user_id, ) except HTTPException as exc: @@ -485,19 +505,19 @@ class ReviewFlowMixin: extracted["protocolo"] = inferred_protocol action = draft.get("action", "cancel") + current_protocol = extracted.get("protocolo") or draft["payload"].get("protocolo") if action == "reschedule" and "nova_data_hora" not in extracted: normalized_new_datetime = self._extract_review_management_datetime_from_message(message) if normalized_new_datetime: extracted["nova_data_hora"] = normalized_new_datetime - if ( - action == "cancel" - and "motivo" not in extracted - and draft["payload"].get("protocolo") - and not has_cancel_intent - ): - free_text = str(message or "").strip() - if free_text and len(free_text) >= 4 and not self._is_affirmative_message(free_text): - extracted["motivo"] = free_text + if action == "cancel" and "motivo" not in extracted and current_protocol: + inferred_reason = self._extract_review_cancel_reason_from_message(message) + if inferred_reason: + extracted["motivo"] = inferred_reason + elif not has_cancel_intent: + free_text = str(message or "").strip() + if free_text and len(free_text) >= 4 and not self._is_affirmative_message(free_text): + extracted["motivo"] = free_text draft["payload"].update(extracted) if action == "reschedule": diff --git a/app/services/orchestration/entity_normalizer.py b/app/services/orchestration/entity_normalizer.py index ec19f8b..008dc4d 100644 --- a/app/services/orchestration/entity_normalizer.py +++ b/app/services/orchestration/entity_normalizer.py @@ -35,6 +35,9 @@ class EntityNormalizer: "cancelar_agendamento": "cancelar_agendamento_revisao", "reschedule_review": "editar_data_revisao", "remarcar_revisao": "editar_data_revisao", + "avaliar_troca_veiculo": "avaliar_veiculo_troca", + "avaliar_troca": "avaliar_veiculo_troca", + "trade_in_evaluation": "avaliar_veiculo_troca", } _TURN_INTENT_ALIASES = { "create_order": "order_create", @@ -156,6 +159,14 @@ class EntityNormalizer: "new_datetime": "nova_data_hora", } _TOOL_ARGUMENT_ALIASES = { + "consultar_estoque": { + "max_results": "limite", + "limit": "limite", + "price_cap": "preco_max", + "max_price": "preco_max", + "vehicle_category": "categoria", + "price_order": "ordenar_preco", + }, "cancelar_pedido": { "order_id": "numero_pedido", "pedido_id": "numero_pedido", @@ -170,6 +181,12 @@ class EntityNormalizer: "id_veiculo": "vehicle_id", "customer_cpf": "cpf", }, + "avaliar_veiculo_troca": { + "vehicle_model": "modelo", + "vehicle_year": "ano", + "quilometragem": "km", + "vehicle_km": "km", + }, "listar_pedidos": { "max_results": "limite", "limit": "limite", @@ -849,6 +866,21 @@ class EntityNormalizer: if canonical_key not in normalized_arguments: normalized_arguments[canonical_key] = value + if normalized_tool_name == "consultar_estoque": + coerced: dict = {} + preco_max = self.normalize_positive_number(normalized_arguments.get("preco_max")) + if preco_max: + coerced["preco_max"] = float(preco_max) + categoria = str(normalized_arguments.get("categoria") or "").strip().lower() + if categoria: + coerced["categoria"] = categoria + ordenar_preco = str(normalized_arguments.get("ordenar_preco") or "").strip().lower() + if ordenar_preco in {"asc", "desc"}: + coerced["ordenar_preco"] = ordenar_preco + limite = self.normalize_positive_number(normalized_arguments.get("limite")) + coerced["limite"] = max(1, min(int(round(limite)) if limite else 5, 5)) + return coerced + if normalized_tool_name == "cancelar_pedido": coerced = self.normalize_cancel_order_fields(normalized_arguments) if "motivo" not in coerced and isinstance(normalized_arguments.get("motivo"), str): @@ -860,6 +892,19 @@ class EntityNormalizer: if normalized_tool_name == "realizar_pedido": return self.normalize_order_fields(normalized_arguments) + if normalized_tool_name == "avaliar_veiculo_troca": + coerced: dict = {} + modelo = str(normalized_arguments.get("modelo") or "").strip() + if modelo: + coerced["modelo"] = modelo + ano = self.normalize_positive_number(normalized_arguments.get("ano")) + if ano: + coerced["ano"] = int(round(ano)) + km = self.normalize_positive_number(normalized_arguments.get("km")) + if km is not None: + coerced["km"] = int(round(km)) + return coerced + if normalized_tool_name == "listar_pedidos": coerced: dict = {} cpf = self.normalize_cpf(normalized_arguments.get("cpf")) @@ -869,8 +914,7 @@ class EntityNormalizer: if status: coerced["status"] = status limite = self.normalize_positive_number(normalized_arguments.get("limite")) - if limite: - coerced["limite"] = max(1, min(int(round(limite)), 50)) + coerced["limite"] = max(1, min(int(round(limite)) if limite else 50, 50)) return coerced if normalized_tool_name == "listar_agendamentos_revisao": @@ -882,8 +926,7 @@ class EntityNormalizer: if status: coerced["status"] = status limite = self.normalize_positive_number(normalized_arguments.get("limite")) - if limite: - coerced["limite"] = max(1, min(int(round(limite)), 100)) + coerced["limite"] = max(1, min(int(round(limite)) if limite else 100, 100)) return coerced if normalized_tool_name == "cancelar_agendamento_revisao": diff --git a/app/services/orchestration/message_planner.py b/app/services/orchestration/message_planner.py index 7ffcd46..3f32280 100644 --- a/app/services/orchestration/message_planner.py +++ b/app/services/orchestration/message_planner.py @@ -196,6 +196,7 @@ class MessagePlanner: "- Se o usuario quiser listar agendamentos de revisao, use intent='review_list', domain='review', action='call_tool' e tool_name='listar_agendamentos_revisao'.\n" "- Se o usuario quiser cancelar um agendamento de revisao, use intent='review_cancel', domain='review' e prefira tool_name='cancelar_agendamento_revisao'.\n" "- Se o usuario quiser remarcar um agendamento de revisao, use intent='review_reschedule', domain='review' e prefira tool_name='editar_data_revisao'.\n" + "- Se o usuario quiser avaliar um veiculo na troca e houver modelo, ano e km, use domain='sales', action='call_tool', tool_name='avaliar_veiculo_troca' e informe esses campos em 'tool_arguments'. Nao peca versao ou placa se isso nao foi solicitado.\n" "- Se faltar dado para continuar um fluxo, use action='ask_missing_fields' e preencha 'missing_fields' e 'response_to_user'.\n" "- Se o usuario estiver escolhendo entre pedidos enfileirados (ex.: '1', '2', 'o segundo'), preencha 'selection_index' com base zero.\n" "- Se for necessaria uma tool de orquestracao, use action compativel e preencha 'tool_name' e 'tool_arguments' quando apropriado.\n" diff --git a/app/services/orchestration/orchestrator_config.py b/app/services/orchestration/orchestrator_config.py index f096c65..02e6673 100644 --- a/app/services/orchestration/orchestrator_config.py +++ b/app/services/orchestration/orchestrator_config.py @@ -40,6 +40,8 @@ LOW_VALUE_RESPONSES = { } DETERMINISTIC_RESPONSE_TOOLS = { + "consultar_estoque", + "avaliar_veiculo_troca", "cancelar_pedido", "listar_pedidos", "listar_agendamentos_revisao", diff --git a/app/services/orchestration/orquestrador_service.py b/app/services/orchestration/orquestrador_service.py index 05b13df..26a3ca6 100644 --- a/app/services/orchestration/orquestrador_service.py +++ b/app/services/orchestration/orquestrador_service.py @@ -1,5 +1,6 @@ import json import logging +import re from datetime import datetime, timedelta from app.core.time_utils import utc_now from time import perf_counter @@ -263,6 +264,8 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): domain_hint = self._domain_from_turn_decision(turn_decision) if domain_hint == "general": domain_hint = self._domain_from_intents(extracted_entities.get("intents", {})) + if self._has_trade_in_evaluation_request(routing_message, turn_decision=turn_decision): + domain_hint = "sales" if self._should_consume_sales_follow_up_in_active_flow( message=routing_message, user_id=user_id, @@ -291,6 +294,15 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): if orchestration_override: return orchestration_override + trade_in_response = await self._try_handle_trade_in_evaluation( + message=routing_message, + user_id=user_id, + extracted_entities=extracted_entities, + turn_decision=turn_decision, + ) + if trade_in_response: + return await finish(trade_in_response, queue_notice=queue_notice) + decision_action = str(turn_decision.get("action") or "") decision_response = str(turn_decision.get("response_to_user") or "").strip() if ( @@ -594,6 +606,8 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): pending_selection = self.state.get_entry("pending_stock_selections", user_id, expire=True) if not pending_selection: return None + if self._has_trade_in_evaluation_request(message): + return None if not self._should_bootstrap_order_from_context( message=message, user_id=user_id, @@ -632,9 +646,27 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): normalized_message = self.normalizer.normalize_text(message).strip() if self._looks_like_explicit_domain_shift_request(normalized_message): return None + if self._has_trade_in_evaluation_request(message): + return None if self._has_order_listing_request(message): return None + pending_cancel_order_draft = self.state.get_entry("pending_cancel_order_drafts", user_id, expire=True) + if pending_cancel_order_draft: + response = await self._try_collect_and_cancel_order( + message=message, + user_id=user_id, + extracted_fields={}, + intents={}, + turn_decision={ + "intent": "order_cancel", + "domain": "sales", + "action": "collect_order_cancel", + }, + ) + if response: + return await finish(response) + pending_order_draft = self.state.get_entry("pending_order_drafts", user_id, expire=True) if pending_order_draft: if self._should_restart_open_order_draft( @@ -668,21 +700,6 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): if response: return await finish(response) - pending_cancel_order_draft = self.state.get_entry("pending_cancel_order_drafts", user_id, expire=True) - if pending_cancel_order_draft: - response = await self._try_collect_and_cancel_order( - message=message, - user_id=user_id, - extracted_fields={}, - intents={}, - turn_decision={ - "intent": "order_cancel", - "domain": "sales", - "action": "collect_order_cancel", - }, - ) - if response: - return await finish(response) return None @@ -704,6 +721,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): self._has_explicit_order_request(message) or self._has_order_listing_request(message) or self._has_stock_listing_request(message) + or self._has_trade_in_evaluation_request(message) ): return None @@ -1078,7 +1096,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): return sanitized: list[dict] = [] - for item in tool_result[:20]: + for item in tool_result[:5]: if not isinstance(item, dict): continue try: @@ -1374,6 +1392,19 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): or cancel_order_fields.get("motivo") or has_open_cancel_order_draft ) + + normalized_message = self._normalize_text(message or "").strip() if message else "" + if message and self._has_explicit_order_request(message): + if decision_intent not in { + "order_list", + "order_cancel", + "review_schedule", + "review_list", + "review_cancel", + "review_reschedule", + } and not any(term in normalized_message for term in {"cancel", "revisao", "agendamento", "remarcar"}): + return True + if decision_intent != "order_create": return False @@ -1503,6 +1534,102 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): ) ) + def _has_trade_in_evaluation_request(self, message: str, turn_decision: dict | None = None) -> bool: + normalized_message = self._normalize_text(message or "").strip() + normalized_tool_name = self.normalizer.normalize_tool_name((turn_decision or {}).get("tool_name")) + if normalized_tool_name == "avaliar_veiculo_troca": + return True + if not normalized_message: + return False + explicit_terms = { + "avaliar meu carro para troca", + "avaliar o meu carro para troca", + "avaliar carro para troca", + "avaliacao para troca", + "avaliar veiculo para troca", + "avaliar o veiculo para troca", + } + if any(term in normalized_message for term in explicit_terms): + return True + return "troca" in normalized_message and any(term in normalized_message for term in {"avaliar", "avaliacao", "estimativa"}) + + def _extract_trade_in_fields( + self, + message: str, + extracted_entities: dict | None = None, + turn_decision: dict | None = None, + ) -> dict: + payload: dict = {} + entities = extracted_entities if isinstance(extracted_entities, dict) else {} + review_fields = self._normalize_review_fields(entities.get("review_fields")) + for field in ("modelo", "ano", "km"): + if field in review_fields: + payload[field] = review_fields[field] + + decision_arguments = self.normalizer.normalize_tool_arguments( + "avaliar_veiculo_troca", + (turn_decision or {}).get("tool_arguments") or {}, + ) + for field in ("modelo", "ano", "km"): + if field in decision_arguments: + payload[field] = decision_arguments[field] + + normalized_message = self._normalize_text(message).strip() + if "modelo" not in payload: + trade_in_model = re.sub(r"^.*?\b(?:troca|entrada)\b\s*[:,-]?\s*", "", normalized_message) + trade_in_model = re.split(r"(? str: + labels = { + "modelo": "o modelo do veiculo", + "ano": "o ano do veiculo", + "km": "a quilometragem atual (km)", + } + itens = [f"- {labels[field]}" for field in missing_fields if field in labels] + return "Para avaliar seu veiculo na troca, preciso dos dados abaixo:\n" + "\n".join(itens) + + async def _try_handle_trade_in_evaluation( + self, + message: str, + user_id: int | None, + extracted_entities: dict | None = None, + turn_decision: dict | None = None, + ) -> str | None: + if not self._has_trade_in_evaluation_request(message, turn_decision=turn_decision): + return None + + arguments = self._extract_trade_in_fields( + message=message, + extracted_entities=extracted_entities, + turn_decision=turn_decision, + ) + missing = [field for field in ("modelo", "ano", "km") if field not in arguments] + if missing: + return self._render_missing_trade_in_fields_prompt(missing) + + try: + tool_result = await self.tool_executor.execute( + "avaliar_veiculo_troca", + arguments, + user_id=user_id, + ) + except HTTPException as exc: + return self._http_exception_detail(exc) + return self._fallback_format_tool_result("avaliar_veiculo_troca", tool_result) + def _parse_json_object(self, text: str): return self.normalizer.parse_json_object(text) diff --git a/app/services/orchestration/response_formatter.py b/app/services/orchestration/response_formatter.py index f0e0f32..981e906 100644 --- a/app/services/orchestration/response_formatter.py +++ b/app/services/orchestration/response_formatter.py @@ -26,16 +26,17 @@ def fallback_format_tool_result(tool_name: str, tool_result: Any) -> str: if tool_name == "consultar_estoque" and isinstance(tool_result, list): if not tool_result: return "Nao encontrei nenhum veiculo com os criterios informados." - linhas = [f"Encontrei {len(tool_result)} veiculo(s):"] - for idx, item in enumerate(tool_result[:10], start=1): + exibidos = tool_result[:5] + linhas = [f"Encontrei {len(tool_result)} veiculo(s). Estas sao as opcoes principais:"] + for idx, item in enumerate(exibidos, start=1): modelo = item.get("modelo", "N/A") categoria = item.get("categoria", "N/A") preco = format_currency_br(item.get("preco")) linhas.append(f"{idx}. {modelo} ({categoria}) - {preco}") - restantes = len(tool_result) - 10 + restantes = len(tool_result) - len(exibidos) if restantes > 0: linhas.append(f"... e mais {restantes} veiculo(s).") - linhas.append("Para escolher, responda com o numero da opcao desejada. Exemplo: 1.") + linhas.append("Se algum te interessar, responda com o numero ou com o modelo.") return "\n".join(linhas) if tool_name == "cancelar_pedido" and isinstance(tool_result, dict): @@ -66,15 +67,12 @@ def fallback_format_tool_result(tool_name: str, tool_result: Any) -> str: if not tool_result: return "Nao encontrei pedidos vinculados a sua conta." linhas = [f"Encontrei {len(tool_result)} pedido(s):"] - for idx, item in enumerate(tool_result[:10], start=1): + for idx, item in enumerate(tool_result, start=1): numero = item.get("numero_pedido", "N/A") modelo = item.get("modelo_veiculo") or "Veiculo nao informado" status = item.get("status", "N/A") valor = format_currency_br(item.get("valor_veiculo")) if item.get("valor_veiculo") is not None else "N/A" linhas.append(f"{idx}. {numero} | {modelo} | {status} | {valor}") - restantes = len(tool_result) - 10 - if restantes > 0: - linhas.append(f"... e mais {restantes} pedido(s).") return "\n".join(linhas) if tool_name == "agendar_revisao" and isinstance(tool_result, dict): @@ -101,21 +99,12 @@ def fallback_format_tool_result(tool_name: str, tool_result: Any) -> str: if not tool_result: return "Nao encontrei agendamentos de revisao para sua conta." linhas = [f"Voce tem {len(tool_result)} agendamento(s):"] - for idx, item in enumerate(tool_result[:12], start=1): + for idx, item in enumerate(tool_result, start=1): protocolo = item.get("protocolo", "N/A") placa = item.get("placa", "N/A") data_hora = format_datetime_for_chat(item.get("data_hora", "N/A")) status = item.get("status", "N/A") - linhas.append(f"{idx}) Protocolo: {protocolo}") - linhas.append(f"Placa: {placa}") - linhas.append(f"Data/Hora: {data_hora} | Status: {status}") - if idx < min(len(tool_result), 12): - linhas.append("") - restantes = len(tool_result) - 12 - if restantes > 0: - if linhas and linhas[-1] != "": - linhas.append("") - linhas.append(f"... e mais {restantes} agendamento(s).") + linhas.append(f"{idx}. {protocolo} | {placa} | {data_hora} | {status}") return "\n".join(linhas) if tool_name == "cancelar_agendamento_revisao" and isinstance(tool_result, dict): @@ -144,6 +133,18 @@ def fallback_format_tool_result(tool_name: str, tool_result: Any) -> str: f"Status: {status}" ) + if tool_name == "avaliar_veiculo_troca" and isinstance(tool_result, dict): + modelo = tool_result.get("modelo", "N/A") + ano = tool_result.get("ano", "N/A") + km = tool_result.get("km", "N/A") + valor = format_currency_br(tool_result.get("valor_estimado_troca")) + return ( + "Estimativa de troca concluida.\n" + f"Veiculo: {modelo} {ano}\n" + f"Quilometragem: {km} km\n" + f"Valor estimado: {valor}" + ) + if tool_name == "validar_cliente_venda" and isinstance(tool_result, dict): aprovado = tool_result.get("aprovado") limite = format_currency_br(tool_result.get("limite_credito")) diff --git a/tests/test_conversation_adjustments.py b/tests/test_conversation_adjustments.py index 5edb94b..0b58906 100644 --- a/tests/test_conversation_adjustments.py +++ b/tests/test_conversation_adjustments.py @@ -10,10 +10,11 @@ from fastapi import HTTPException from app.services.flows.order_flow import OrderFlowMixin from app.services.flows.review_flow import ReviewFlowMixin -from app.integrations.telegram_satellite_service import _ensure_supported_runtime_configuration +from app.integrations.telegram_satellite_service import _ensure_supported_runtime_configuration, _split_telegram_text from app.models.tool_model import ToolDefinition from app.services.orchestration.conversation_policy import ConversationPolicy from app.services.orchestration.entity_normalizer import EntityNormalizer +from app.services.orchestration.response_formatter import fallback_format_tool_result from app.services.tools.tool_registry import ToolRegistry from app.services.tools.handlers import _parse_data_hora_revisao @@ -322,6 +323,43 @@ class ConversationAdjustmentsTests(unittest.TestCase): ): _ensure_supported_runtime_configuration() + def test_telegram_satellite_splits_long_messages_safely(self): + text = ("A" * 3900) + "\n" + ("B" * 3900) + "\n" + ("C" * 3900) + + chunks = _split_telegram_text(text) + + self.assertGreater(len(chunks), 1) + self.assertTrue(all(len(chunk) <= 3800 for chunk in chunks)) + rebuilt = "\n".join(chunks) + self.assertEqual(rebuilt.replace("\n", ""), text.replace("\n", "")) + self.assertIn("A" * 100, rebuilt) + self.assertIn("B" * 100, rebuilt) + self.assertIn("C" * 100, rebuilt) + + def test_review_listing_formatter_is_compact_and_complete(self): + response = fallback_format_tool_result( + "listar_agendamentos_revisao", + [ + { + "protocolo": "REV-1", + "placa": "ABC1234", + "data_hora": "2026-03-19T09:30:00", + "status": "agendado", + }, + { + "protocolo": "REV-2", + "placa": "XYZ9999", + "data_hora": "2026-03-20T10:00:00", + "status": "cancelado", + }, + ], + ) + + self.assertIn("Voce tem 2 agendamento(s):", response) + self.assertIn("1. REV-1 | ABC1234 |", response) + self.assertIn("2. REV-2 | XYZ9999 |", response) + self.assertNotIn("\n\n", response) + def test_defer_flow_cancel_when_order_cancel_draft_waits_for_reason(self): state = FakeState( entries={ @@ -610,6 +648,7 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase): ) self.assertEqual(registry.calls[0][0], "listar_pedidos") + self.assertEqual(registry.calls[0][1]["limite"], 50) self.assertIn("Encontrei 2 pedido(s):", response) self.assertIsNotNone(state.get_entry("pending_order_drafts", 10)) @@ -628,6 +667,42 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(response) self.assertEqual(registry.calls, []) + async def test_order_flow_returns_clear_guidance_for_invalid_cpf_follow_up(self): + state = FakeState( + entries={ + "pending_order_drafts": { + 10: { + "payload": {"vehicle_id": 1, "modelo_veiculo": "Honda Civic 2021", "valor_veiculo": 48500.0}, + "expires_at": utc_now() + timedelta(minutes=30), + } + } + }, + contexts={ + 10: { + "generic_memory": {}, + "shared_memory": {}, + "last_stock_results": [ + {"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 48500.0} + ], + "selected_vehicle": {"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 48500.0}, + } + } + ) + registry = FakeRegistry() + flow = OrderFlowHarness(state=state, registry=registry) + + response = await flow._try_collect_and_create_order( + message="123", + user_id=10, + extracted_fields={}, + intents={}, + turn_decision={"intent": "order_create", "domain": "sales", "action": "collect_order_create"}, + ) + + self.assertEqual(response, "Para seguir com o pedido, preciso de um CPF valido. Pode me informar novamente?") + self.assertEqual(registry.calls, []) + self.assertIsNotNone(state.get_entry("pending_order_drafts", 10)) + async def test_order_flow_auto_lists_stock_on_first_purchase_message_when_budget_exists(self): state = FakeState( contexts={ @@ -1485,6 +1560,61 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(draft["payload"].get("cpf"), "12345678909") self.assertNotIn("vehicle_id", draft["payload"]) + async def test_order_flow_clears_draft_after_non_retryable_credit_rejection(self): + state = FakeState( + entries={ + "pending_order_drafts": { + 10: { + "payload": {"cpf": "12345678909", "vehicle_id": 9, "modelo_veiculo": "Hyundai HB20S 2022"}, + "expires_at": utc_now() + timedelta(minutes=30), + } + } + }, + contexts={ + 10: { + "active_domain": "sales", + "generic_memory": {"cpf": "12345678909"}, + "shared_memory": {"cpf": "12345678909"}, + "last_stock_results": [ + {"id": 9, "modelo": "Hyundai HB20S 2022", "categoria": "sedan", "preco": 76000.0}, + ], + "selected_vehicle": {"id": 9, "modelo": "Hyundai HB20S 2022", "categoria": "sedan", "preco": 76000.0}, + "pending_single_vehicle_confirmation": {"id": 9, "modelo": "Hyundai HB20S 2022", "categoria": "sedan", "preco": 76000.0}, + } + }, + ) + registry = FakeRegistry() + registry.raise_http_exception = HTTPException( + status_code=400, + detail={ + "code": "credit_not_approved", + "message": "Cliente nao aprovado para este valor. Limite disponivel: R$ 70878.00.", + "retryable": False, + "field": "cpf", + }, + ) + flow = OrderFlowHarness(state=state, registry=registry) + + async def fake_hydrate_mock_customer_from_cpf(cpf: str, user_id: int | None = None): + return {"cpf": cpf, "user_id": user_id} + + with patch( + "app.services.flows.order_flow.hydrate_mock_customer_from_cpf", + new=fake_hydrate_mock_customer_from_cpf, + ): + response = await flow._try_collect_and_create_order( + message="12345678909", + user_id=10, + extracted_fields={}, + intents={}, + ) + + self.assertIn("nao aprovado", response) + self.assertIsNone(state.get_entry("pending_order_drafts", 10)) + self.assertEqual(state.get_user_context(10)["selected_vehicle"], None) + self.assertEqual(state.get_user_context(10)["last_stock_results"], []) + self.assertEqual(state.get_user_context(10).get("pending_single_vehicle_confirmation"), None) + async def test_order_flow_refreshes_stale_stock_results_when_budget_changes(self): state = FakeState( contexts={ @@ -2349,6 +2479,24 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase): self.assertIsNotNone(draft) self.assertEqual(draft["payload"].get("placa"), "ABC1234") self.assertNotIn("data_hora", draft["payload"]) + async def test_review_management_preserves_reason_from_single_cancel_message(self): + state = FakeState() + registry = FakeRegistry() + flow = ReviewFlowHarness(state=state, registry=registry) + + response = await flow._try_handle_review_management( + message="Quero cancelar a revisao do protocolo REV-20260313-F754AF27 porque nao vou poder ir", + user_id=21, + extracted_fields={}, + intents={}, + turn_decision={"intent": "review_cancel", "domain": "review", "action": "answer_user"}, + ) + + self.assertEqual(registry.calls[0][0], "cancelar_agendamento_revisao") + self.assertEqual(registry.calls[0][1]["protocolo"], "REV-20260313-F754AF27") + self.assertEqual(registry.calls[0][1]["motivo"], "nao vou poder ir") + self.assertIn("cancelar_agendamento_revisao", response) + async def test_review_management_infers_cancel_intent_from_protocol_message(self): state = FakeState() @@ -2454,6 +2602,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase): ) self.assertEqual(registry.calls[0][0], "listar_agendamentos_revisao") + self.assertEqual(registry.calls[0][1]["limite"], 100) self.assertIn("listar_agendamentos_revisao", response) async def test_review_schedule_clears_open_management_draft(self): @@ -2727,4 +2876,3 @@ class ToolRegistryExecutionTests(unittest.IsolatedAsyncioTestCase): if __name__ == "__main__": unittest.main() - diff --git a/tests/test_turn_decision_contract.py b/tests/test_turn_decision_contract.py index fcc8be1..5616bea 100644 --- a/tests/test_turn_decision_contract.py +++ b/tests/test_turn_decision_contract.py @@ -951,11 +951,6 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): service._fallback_format_tool_result = lambda tool_name, tool_result: ( f"Pedido {tool_result['numero_pedido']} atualizado.\nStatus: {tool_result['status']}" ) - - async def fake_render_tool_response_with_fallback(**kwargs): - return "nao deveria usar llm" - - service._render_tool_response_with_fallback = fake_render_tool_response_with_fallback service._http_exception_detail = lambda exc: str(exc) service._is_low_value_response = lambda text: False @@ -1438,6 +1433,152 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertIn("Encontrei 2 veiculo(s):", response) + async def test_handle_message_prioritizes_order_flow_for_explicit_order_request_without_extracted_fields(self): + state = FakeState( + contexts={ + 1: { + "active_domain": "general", + "generic_memory": {}, + "shared_memory": {}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, + } + } + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service.policy = ConversationPolicy(service=service) + service._empty_extraction_payload = service.normalizer.empty_extraction_payload + service._log_turn_event = lambda *args, **kwargs: None + service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response + + async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None): + return base_response + + service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order + service._upsert_user_context = lambda user_id: None + + async def fake_extract_turn_decision(message: str, user_id: int | None): + return { + "intent": "general", + "domain": "general", + "action": "answer_user", + "entities": { + "generic_memory": {}, + "review_fields": {}, + "review_management_fields": {}, + "order_fields": {}, + "cancel_order_fields": {}, + }, + "missing_fields": [], + "selection_index": None, + "tool_name": None, + "tool_arguments": {}, + "response_to_user": "Claro! Voce gostaria de fazer um pedido de um veiculo ou agendar uma revisao?", + } + + service._extract_turn_decision_with_llm = fake_extract_turn_decision + + async def fake_try_resolve_pending_order_selection(**kwargs): + return None + + service._try_resolve_pending_order_selection = fake_try_resolve_pending_order_selection + + async def fake_try_continue_queued_order(**kwargs): + return None + + service._try_continue_queued_order = fake_try_continue_queued_order + + async def fake_extract_message_plan(message: str, user_id: int | None): + return { + "orders": [ + { + "domain": "sales", + "message": message, + "entities": service.normalizer.empty_extraction_payload(), + } + ] + } + + service._extract_message_plan_with_llm = fake_extract_message_plan + service._prepare_message_for_single_order = lambda message, user_id, routing_plan=None: (message, None, None) + service._resolve_entities_for_message_plan = lambda message_plan, routed_message: service.normalizer.empty_extraction_payload() + + async def fake_extract_entities(message: str, user_id: int | None): + return { + "generic_memory": {}, + "review_fields": {}, + "review_management_fields": {}, + "order_fields": {}, + "cancel_order_fields": {}, + "intents": {}, + } + + service._extract_entities_with_llm = fake_extract_entities + + async def fake_extract_missing_sales_search_context_with_llm(**kwargs): + return {} + + service._extract_missing_sales_search_context_with_llm = fake_extract_missing_sales_search_context_with_llm + service._domain_from_intents = lambda intents: "general" + service._handle_context_switch = lambda **kwargs: None + service._update_active_domain = lambda **kwargs: None + + async def fake_try_execute_orchestration_control_tool(**kwargs): + return None + + service._try_execute_orchestration_control_tool = fake_try_execute_orchestration_control_tool + + async def fake_try_execute_business_tool_from_turn_decision(**kwargs): + return None + + service._try_execute_business_tool_from_turn_decision = fake_try_execute_business_tool_from_turn_decision + + async def fake_try_handle_review_management(**kwargs): + return None + + service._try_handle_review_management = fake_try_handle_review_management + + async def fake_try_confirm_pending_review(**kwargs): + return None + + service._try_confirm_pending_review = fake_try_confirm_pending_review + + async def fake_try_collect_and_schedule_review(**kwargs): + return None + + service._try_collect_and_schedule_review = fake_try_collect_and_schedule_review + + async def fake_try_collect_and_cancel_order(**kwargs): + return None + + service._try_collect_and_cancel_order = fake_try_collect_and_cancel_order + + async def fake_try_handle_order_listing(**kwargs): + return None + + service._try_handle_order_listing = fake_try_handle_order_listing + + async def fake_try_collect_and_create_order(**kwargs): + return ( + "Para seguir com o pedido, me diga qual carro voce procura.\n" + "Se preferir, posso listar opcoes por faixa de preco, modelo ou tipo de carro." + ) + + service._try_collect_and_create_order = fake_try_collect_and_create_order + + response = await service.handle_message( + "Quero fazer um pedido", + user_id=1, + ) + + self.assertIn("Para seguir com o pedido", response) + self.assertNotIn("Qual veiculo voce gostaria de pedir", response) + def test_should_prioritize_review_flow_when_review_draft_is_open(self): state = FakeState( entries={ @@ -2568,6 +2709,70 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(response) self.assertIsNotNone(state.get_entry("pending_order_drafts", 1)) + async def test_active_sales_follow_up_prioritizes_cancel_order_over_stale_order_create_draft(self): + state = FakeState( + entries={ + "pending_order_drafts": { + 1: { + "payload": { + "cpf": "12345678909", + "vehicle_id": 15, + "modelo_veiculo": "Volkswagen T-Cross 2022", + "valor_veiculo": 73224.0, + }, + "expires_at": utc_now() + timedelta(minutes=15), + } + }, + "pending_cancel_order_drafts": { + 1: { + "payload": {"numero_pedido": "PED-20260312110556-BBA37F"}, + "expires_at": utc_now() + timedelta(minutes=15), + } + }, + }, + contexts={ + 1: { + "active_domain": "sales", + "active_task": "order_cancel", + "generic_memory": {"cpf": "12345678909"}, + "shared_memory": {"cpf": "12345678909"}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, + } + }, + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service._get_user_context = lambda user_id: state.get_user_context(user_id) + service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context) + + async def fake_try_collect_and_create_order(**kwargs): + raise AssertionError("nao deveria priorizar order_create enquanto order_cancel aguarda motivo") + + async def fake_try_collect_and_cancel_order(**kwargs): + self.assertEqual(kwargs["message"], "desisti da compra") + self.assertEqual(kwargs["user_id"], 1) + return "Pedido PED-20260312110556-BBA37F atualizado.\nStatus: Cancelado" + + service._try_collect_and_create_order = fake_try_collect_and_create_order + service._try_collect_and_cancel_order = fake_try_collect_and_cancel_order + + async def finish(response: str, queue_notice: str | None = None): + return response + + response = await service._try_handle_active_sales_follow_up( + message="desisti da compra", + user_id=1, + finish=finish, + ) + + self.assertIn("Pedido PED-20260312110556-BBA37F atualizado.", response) + self.assertIn("Status: Cancelado", response) + async def test_active_sales_follow_up_allows_new_budget_search_to_reset_open_order_draft(self): state = FakeState( entries={ @@ -2746,57 +2951,135 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertTrue(prioritized) - async def test_pending_order_selection_prefers_turn_decision_domain(self): + def test_should_prioritize_order_flow_for_explicit_order_request_without_entities(self): state = FakeState( contexts={ - 9: { - "pending_order_selection": { - "orders": [ - {"domain": "review", "message": "agendar revisao", "memory_seed": {}}, - {"domain": "sales", "message": "fazer pedido", "memory_seed": {}}, - ], - "expires_at": utc_now() + timedelta(minutes=15), - }, - "order_queue": [], + 1: { "active_domain": "general", "generic_memory": {}, + "shared_memory": {}, + "last_stock_results": [], + "selected_vehicle": None, } } ) - policy = ConversationPolicy(service=FakePolicyService(state)) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service._get_user_context = lambda user_id: state.get_user_context(user_id) - response = await policy.try_resolve_pending_order_selection( - message="quero comprar", - user_id=9, - turn_decision={"domain": "sales", "intent": "order_create", "action": "collect_order_create"}, + prioritized = service._should_prioritize_order_flow( + turn_decision={"intent": "order_create", "domain": "sales", "action": "answer_user"}, + extracted_entities={ + "generic_memory": {}, + "review_fields": {}, + "review_management_fields": {}, + "order_fields": {}, + "cancel_order_fields": {}, + "intents": {}, + }, + user_id=1, + message="Quero fazer um pedido", ) - self.assertIn("Vou comecar por: Venda: fazer pedido", response) + self.assertTrue(prioritized) - async def test_pending_order_selection_prefers_turn_decision_selection_index(self): + def test_should_prioritize_order_flow_for_explicit_order_request_even_when_model_returns_general(self): state = FakeState( contexts={ - 9: { - "pending_order_selection": { - "orders": [ - {"domain": "review", "message": "agendar revisao", "memory_seed": {}}, - {"domain": "sales", "message": "fazer pedido", "memory_seed": {}}, - ], - "expires_at": utc_now() + timedelta(minutes=15), - }, - "order_queue": [], + 1: { "active_domain": "general", "generic_memory": {}, + "shared_memory": {}, + "last_stock_results": [], + "selected_vehicle": None, } } ) - policy = ConversationPolicy(service=FakePolicyService(state)) - - response = await policy.try_resolve_pending_order_selection( - message="esse", - user_id=9, - turn_decision={"domain": "general", "intent": "general", "action": "answer_user", "selection_index": 1}, - ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service._get_user_context = lambda user_id: state.get_user_context(user_id) + + prioritized = service._should_prioritize_order_flow( + turn_decision={"intent": "general", "domain": "general", "action": "answer_user"}, + extracted_entities={ + "generic_memory": {}, + "review_fields": {}, + "review_management_fields": {}, + "order_fields": {}, + "cancel_order_fields": {}, + "intents": {}, + }, + user_id=1, + message="Quero fazer um pedido", + ) + + self.assertTrue(prioritized) + + def test_normalize_tool_name_maps_trade_in_alias_and_arguments(self): + normalizer = EntityNormalizer() + + tool_name = normalizer.normalize_tool_name("avaliar_troca_veiculo") + arguments = normalizer.normalize_tool_arguments( + tool_name, + {"modelo": "Onix", "ano": 2020, "quilometragem": 45000}, + ) + + self.assertEqual(tool_name, "avaliar_veiculo_troca") + self.assertEqual(arguments, {"modelo": "Onix", "ano": 2020, "km": 45000}) + + async def test_pending_order_selection_prefers_turn_decision_domain(self): + state = FakeState( + contexts={ + 9: { + "pending_order_selection": { + "orders": [ + {"domain": "review", "message": "agendar revisao", "memory_seed": {}}, + {"domain": "sales", "message": "fazer pedido", "memory_seed": {}}, + ], + "expires_at": utc_now() + timedelta(minutes=15), + }, + "order_queue": [], + "active_domain": "general", + "generic_memory": {}, + } + } + ) + policy = ConversationPolicy(service=FakePolicyService(state)) + + response = await policy.try_resolve_pending_order_selection( + message="quero comprar", + user_id=9, + turn_decision={"domain": "sales", "intent": "order_create", "action": "collect_order_create"}, + ) + + self.assertIn("Vou comecar por: Venda: fazer pedido", response) + + async def test_pending_order_selection_prefers_turn_decision_selection_index(self): + state = FakeState( + contexts={ + 9: { + "pending_order_selection": { + "orders": [ + {"domain": "review", "message": "agendar revisao", "memory_seed": {}}, + {"domain": "sales", "message": "fazer pedido", "memory_seed": {}}, + ], + "expires_at": utc_now() + timedelta(minutes=15), + }, + "order_queue": [], + "active_domain": "general", + "generic_memory": {}, + } + } + ) + policy = ConversationPolicy(service=FakePolicyService(state)) + + response = await policy.try_resolve_pending_order_selection( + message="esse", + user_id=9, + turn_decision={"domain": "general", "intent": "general", "action": "answer_user", "selection_index": 1}, + ) self.assertIn("Vou comecar por: Venda: fazer pedido", response) @@ -2898,5 +3181,329 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(service._get_user_context(9).get("order_queue"), []) + async def test_tool_continuar_proximo_pedido_reports_empty_queue(self): + state = FakeState( + contexts={ + 1: { + "active_domain": "sales", + "generic_memory": {}, + "shared_memory": {}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, + } + } + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service.policy = ConversationPolicy(service=service) + service._get_user_context = lambda user_id: state.get_user_context(user_id) + service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context) + + response = await service._tool_continuar_proximo_pedido(user_id=1) + + self.assertEqual(response, "Nao ha pedidos pendentes na fila para continuar.") + + async def test_tool_cancelar_fluxo_atual_preserves_queue(self): + state = FakeState( + entries={ + "pending_order_drafts": { + 1: { + "payload": {"vehicle_id": 7}, + "expires_at": utc_now() + timedelta(minutes=15), + } + } + }, + contexts={ + 1: { + "active_domain": "sales", + "active_task": "order_create", + "generic_memory": {"orcamento_max": 70000}, + "shared_memory": {"orcamento_max": 70000}, + "order_queue": [{"domain": "review", "message": "agendar revisao"}], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [{"id": 7, "modelo": "Fiat Argo 2020", "categoria": "suv", "preco": 61857.0}], + "selected_vehicle": {"id": 7, "modelo": "Fiat Argo 2020", "categoria": "suv", "preco": 61857.0}, + } + } + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service.policy = ConversationPolicy(service=service) + service._get_user_context = lambda user_id: state.get_user_context(user_id) + service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context) + + result = await service._tool_cancelar_fluxo_atual(user_id=1) + + self.assertEqual(result["message"], "Fluxo atual de compra de veiculo cancelado.") + self.assertIsNone(state.get_entry("pending_order_drafts", 1)) + self.assertEqual(state.get_user_context(1)["order_queue"], [{"domain": "review", "message": "agendar revisao"}]) + + async def test_tool_descartar_pedidos_pendentes_preserves_active_flow(self): + state = FakeState( + entries={ + "pending_order_drafts": { + 1: { + "payload": {"vehicle_id": 7}, + "expires_at": utc_now() + timedelta(minutes=15), + } + } + }, + contexts={ + 1: { + "active_domain": "sales", + "active_task": "order_create", + "generic_memory": {"orcamento_max": 70000}, + "shared_memory": {"orcamento_max": 70000}, + "order_queue": [{"domain": "review", "message": "agendar revisao"}], + "pending_order_selection": { + "orders": [ + {"domain": "sales", "message": "comprar carro"}, + {"domain": "review", "message": "remarcar revisao"}, + ], + "expires_at": utc_now() + timedelta(minutes=15), + }, + "pending_switch": { + "target_domain": "review", + "queued_message": "agendar revisao", + "expires_at": utc_now() + timedelta(minutes=15), + }, + "last_stock_results": [], + "selected_vehicle": None, + } + } + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service.policy = ConversationPolicy(service=service) + service._get_user_context = lambda user_id: state.get_user_context(user_id) + service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context) + + result = await service._tool_descartar_pedidos_pendentes(user_id=1) + + self.assertEqual(result["message"], "Descartei 4 pedidos pendentes da fila.") + self.assertIsNotNone(state.get_entry("pending_order_drafts", 1)) + self.assertEqual(state.get_user_context(1)["order_queue"], []) + self.assertIsNone(state.get_user_context(1)["pending_order_selection"]) + self.assertIsNone(state.get_user_context(1)["pending_switch"]) + + + async def test_handle_message_prioritizes_trade_in_evaluation_over_freeform_answer(self): + state = FakeState( + contexts={ + 1: { + "active_domain": "general", + "generic_memory": {}, + "shared_memory": {}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, + } + } + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service.policy = ConversationPolicy(service=service) + service.tool_executor = FakeToolExecutor( + result={ + "modelo": "Onix", + "ano": 2020, + "km": 45000, + "valor_estimado_troca": 65432.0, + } + ) + service._empty_extraction_payload = service.normalizer.empty_extraction_payload + service._log_turn_event = lambda *args, **kwargs: None + service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response + service._fallback_format_tool_result = lambda tool_name, tool_result: ( + "Estimativa de troca concluida.\n" + f"Veiculo: {tool_result['modelo']} {tool_result['ano']}\n" + f"Quilometragem: {tool_result['km']} km\n" + f"Valor estimado: R$ {tool_result['valor_estimado_troca']:.2f}" + ) + service._http_exception_detail = lambda exc: str(exc) + service._get_user_context = lambda user_id: state.get_user_context(user_id) + service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context) + + async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None): + return base_response + + service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order + service._upsert_user_context = lambda user_id: None + + async def fake_try_handle_pending_stock_selection_follow_up(**kwargs): + return None + + service._try_handle_pending_stock_selection_follow_up = fake_try_handle_pending_stock_selection_follow_up + + async def fake_try_handle_active_sales_follow_up(**kwargs): + return None + + service._try_handle_active_sales_follow_up = fake_try_handle_active_sales_follow_up + + async def fake_try_handle_active_review_follow_up(**kwargs): + return None + + service._try_handle_active_review_follow_up = fake_try_handle_active_review_follow_up + + async def fake_try_handle_immediate_context_reset(**kwargs): + return None + + service._try_handle_immediate_context_reset = fake_try_handle_immediate_context_reset + + async def fake_try_resolve_pending_order_selection(**kwargs): + return None + + service._try_resolve_pending_order_selection = fake_try_resolve_pending_order_selection + + async def fake_try_continue_queued_order(**kwargs): + return None + + service._try_continue_queued_order = fake_try_continue_queued_order + + async def fake_extract_turn_decision(message: str, user_id: int | None): + return { + "intent": "general", + "domain": "general", + "action": "answer_user", + "entities": service.normalizer.empty_extraction_payload(), + "missing_fields": [], + "selection_index": None, + "tool_name": None, + "tool_arguments": {}, + "response_to_user": "Legal! Para fazer a avaliacao, preciso da versao e da placa.", + } + + service._extract_turn_decision_with_llm = fake_extract_turn_decision + + async def fake_extract_message_plan(message: str, user_id: int | None): + return { + "orders": [ + { + "domain": "sales", + "message": message, + "entities": service.normalizer.empty_extraction_payload(), + } + ] + } + + service._extract_message_plan_with_llm = fake_extract_message_plan + service._prepare_message_for_single_order = lambda message, user_id, routing_plan=None: (message, None, None) + service._resolve_entities_for_message_plan = lambda message_plan, routed_message: service.normalizer.empty_extraction_payload() + + async def fake_extract_entities(message: str, user_id: int | None): + return service.normalizer.empty_extraction_payload() + + service._extract_entities_with_llm = fake_extract_entities + + async def fake_extract_missing_sales_search_context_with_llm(**kwargs): + return {} + + service._extract_missing_sales_search_context_with_llm = fake_extract_missing_sales_search_context_with_llm + service._domain_from_intents = lambda intents: "general" + service._handle_context_switch = lambda **kwargs: None + service._update_active_domain = lambda **kwargs: None + + async def fake_try_execute_orchestration_control_tool(**kwargs): + return None + + service._try_execute_orchestration_control_tool = fake_try_execute_orchestration_control_tool + + async def fake_try_handle_review_management(**kwargs): + raise AssertionError("nao deveria entrar em gerenciamento de revisao para avaliacao de troca") + + service._try_handle_review_management = fake_try_handle_review_management + + async def fake_try_confirm_pending_review(**kwargs): + raise AssertionError("nao deveria entrar em confirmacao de revisao para avaliacao de troca") + + service._try_confirm_pending_review = fake_try_confirm_pending_review + + async def fake_try_collect_and_schedule_review(**kwargs): + raise AssertionError("nao deveria entrar em agendamento de revisao para avaliacao de troca") + + service._try_collect_and_schedule_review = fake_try_collect_and_schedule_review + + async def fake_try_collect_and_cancel_order(**kwargs): + raise AssertionError("nao deveria entrar em cancelamento de pedido para avaliacao de troca") + + service._try_collect_and_cancel_order = fake_try_collect_and_cancel_order + + async def fake_try_handle_order_listing(**kwargs): + raise AssertionError("nao deveria entrar em listagem de pedidos para avaliacao de troca") + + service._try_handle_order_listing = fake_try_handle_order_listing + + async def fake_try_collect_and_create_order(**kwargs): + raise AssertionError("nao deveria entrar em compra para avaliacao de troca") + + service._try_collect_and_create_order = fake_try_collect_and_create_order + + response = await service.handle_message( + "Quero avaliar meu carro para troca: Onix 2020, 45000 km", + user_id=1, + ) + + self.assertIn("Estimativa de troca concluida", response) + self.assertEqual( + service.tool_executor.calls, + [("avaliar_veiculo_troca", {"modelo": "Onix", "ano": 2020, "km": 45000}, 1)], + ) + + async def test_active_review_follow_up_ignores_trade_in_request(self): + state = FakeState( + entries={ + "pending_review_drafts": { + 1: { + "payload": {"placa": "ABC1234"}, + "expires_at": utc_now() + timedelta(minutes=15), + } + } + }, + contexts={ + 1: { + "active_domain": "review", + "generic_memory": {}, + "shared_memory": {}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, + } + } + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service._get_user_context = lambda user_id: state.get_user_context(user_id) + service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context) + + async def fake_try_collect_and_schedule_review(**kwargs): + raise AssertionError("nao deveria consumir avaliacao de troca como follow-up de revisao") + + service._try_collect_and_schedule_review = fake_try_collect_and_schedule_review + + async def finish(response: str, queue_notice: str | None = None): + return response + + response = await service._try_handle_active_review_follow_up( + message="Quero avaliar meu carro para troca: Onix 2020, 45000 km", + user_id=1, + finish=finish, + ) + + self.assertIsNone(response) + + if __name__ == "__main__": unittest.main()