From 134a5fef410e9790a4fc6e56a9ac9270dd399490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vitor=20Hugo=20Belorio=20Sim=C3=A3o?= Date: Tue, 10 Mar 2026 11:11:14 -0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=97=20feat(sales):=20vincular=20pedido?= =?UTF-8?q?=20ao=20veiculo=20selecionado=20e=20endurecer=20fluxos=20conver?= =?UTF-8?q?sacionais?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passa a criar pedidos de compra a partir de um veiculo concreto do estoque selecionado na conversa, reaproveitando a ultima consulta e exibindo o modelo escolhido na resposta final. Tambem endurece a orquestracao contra vazamento de contexto entre compra, cancelamento e revisao, preserva o estado necessario no fluxo e adiciona testes de regressao para os cenarios validados no Telegram. --- app/api/schemas.py | 2 +- app/db/mock_models.py | 3 + app/db/tool_seed.py | 13 +- app/services/flows/order_flow.py | 166 +++++++-- .../orchestration/conversation_policy.py | 61 +++- .../orchestration/conversation_state_store.py | 6 +- .../orchestration/entity_normalizer.py | 16 +- app/services/orchestration/message_planner.py | 5 +- .../orchestration/orchestrator_config.py | 2 +- .../orchestration/orquestrador_service.py | 64 +++- .../orchestration/response_formatter.py | 6 +- app/services/tools/handlers.py | 86 ++++- tests/test_conversation_adjustments.py | 315 ++++++++++++++++++ 13 files changed, 671 insertions(+), 74 deletions(-) create mode 100644 tests/test_conversation_adjustments.py diff --git a/app/api/schemas.py b/app/api/schemas.py index da87bf3..175a67c 100644 --- a/app/api/schemas.py +++ b/app/api/schemas.py @@ -87,5 +87,5 @@ class CancelarPedidoRequest(BaseModel): class RealizarPedidoRequest(BaseModel): cpf: str - valor_veiculo: float + vehicle_id: int user_id: Optional[int] = None diff --git a/app/db/mock_models.py b/app/db/mock_models.py index 17eeb54..a29e0ce 100644 --- a/app/db/mock_models.py +++ b/app/db/mock_models.py @@ -54,6 +54,9 @@ class Order(MockBase): numero_pedido = Column(String(40), unique=True, nullable=False, index=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) cpf = Column(String(11), ForeignKey("customers.cpf"), nullable=False, index=True) + vehicle_id = Column(Integer, ForeignKey("vehicles.id"), nullable=True, index=True) + modelo_veiculo = Column(String(120), nullable=True) + valor_veiculo = Column(Float, nullable=True) status = Column(String(20), nullable=False, default="Ativo") motivo_cancelamento = Column(Text, nullable=True) data_cancelamento = Column(DateTime, nullable=True) diff --git a/app/db/tool_seed.py b/app/db/tool_seed.py index 2b81582..7bba095 100644 --- a/app/db/tool_seed.py +++ b/app/db/tool_seed.py @@ -217,8 +217,9 @@ def get_tools_definitions(): "name": "realizar_pedido", "description": ( "Use esta ferramenta quando o cliente quiser efetivar uma compra/pedido. " - "Ela recebe CPF e valor do veiculo, valida credito e, se aprovado, cria " - "um novo pedido com numero unico." + "Ela recebe CPF e o identificador do veiculo escolhido no estoque, valida " + "credito com base no preco real do carro e, se aprovado, cria um novo pedido " + "com numero unico." ), "parameters": { "type": "object", @@ -227,12 +228,12 @@ def get_tools_definitions(): "type": "string", "description": "CPF do cliente, com ou sem formatacao.", }, - "valor_veiculo": { - "type": "number", - "description": "Valor do veiculo em reais (BRL) para gerar o pedido.", + "vehicle_id": { + "type": "integer", + "description": "Codigo do veiculo escolhido no estoque.", }, }, - "required": ["cpf", "valor_veiculo"], + "required": ["cpf", "vehicle_id"], }, }, { diff --git a/app/services/flows/order_flow.py b/app/services/flows/order_flow.py index 7d3e19b..c7c6f88 100644 --- a/app/services/flows/order_flow.py +++ b/app/services/flows/order_flow.py @@ -4,7 +4,7 @@ from datetime import datetime, timedelta from fastapi import HTTPException from app.db.mock_database import SessionMockLocal -from app.db.mock_models import User +from app.db.mock_models import User, Vehicle from app.services.orchestration.orchestrator_config import ( CANCEL_ORDER_REQUIRED_FIELDS, ORDER_REQUIRED_FIELDS, @@ -50,18 +50,6 @@ class OrderFlowMixin: second_digit = 0 if second_digit >= 10 else second_digit return second_digit == numbers[10] - def _try_prefill_order_value_from_memory(self, user_id: int | None, payload: dict) -> None: - if user_id is None or payload.get("valor_veiculo") is not None: - return - - context = self._get_user_context(user_id) - if not context: - return - memory = context.get("generic_memory", {}) - budget = memory.get("orcamento_max") - if isinstance(budget, (int, float)) and budget > 0: - payload["valor_veiculo"] = float(budget) - def _try_prefill_order_cpf_from_memory(self, user_id: int | None, payload: dict) -> None: if user_id is None or payload.get("cpf"): return @@ -86,14 +74,121 @@ class OrderFlowMixin: finally: db.close() + def _get_last_stock_results(self, user_id: int | None) -> list[dict]: + context = self._get_user_context(user_id) + if not context: + return [] + stock_results = context.get("last_stock_results") or [] + return stock_results if isinstance(stock_results, list) else [] + + def _get_selected_vehicle(self, user_id: int | None) -> dict | None: + context = self._get_user_context(user_id) + if not context: + return None + selected_vehicle = context.get("selected_vehicle") + return dict(selected_vehicle) if isinstance(selected_vehicle, dict) else None + + def _store_selected_vehicle(self, user_id: int | None, vehicle: dict | None) -> None: + if user_id is None: + return + context = self._get_user_context(user_id) + if not context: + return + context["selected_vehicle"] = dict(vehicle) if isinstance(vehicle, dict) else None + + def _vehicle_to_payload(self, vehicle: dict) -> dict: + return { + "vehicle_id": int(vehicle["id"]), + "modelo_veiculo": str(vehicle["modelo"]), + "valor_veiculo": round(float(vehicle["preco"]), 2), + } + + def _try_prefill_order_vehicle_from_context(self, user_id: int | None, payload: dict) -> None: + if user_id is None or payload.get("vehicle_id"): + return + selected_vehicle = self._get_selected_vehicle(user_id=user_id) + if selected_vehicle: + payload.update(self._vehicle_to_payload(selected_vehicle)) + + def _match_vehicle_from_message_index(self, message: str, stock_results: list[dict]) -> dict | None: + tokens = [token for token in re.findall(r"\d+", str(message or "")) if token.isdigit()] + if not tokens: + return None + choice = int(tokens[0]) + if 1 <= choice <= len(stock_results): + return stock_results[choice - 1] + return None + + def _match_vehicle_from_message_model(self, message: str, stock_results: list[dict]) -> dict | None: + normalized_message = self._normalize_text(message) + matches = [] + for item in stock_results: + normalized_model = self._normalize_text(str(item.get("modelo") or "")) + if normalized_model and normalized_model in normalized_message: + matches.append(item) + if len(matches) == 1: + return matches[0] + return None + + def _load_vehicle_by_id(self, vehicle_id: int) -> dict | None: + db = SessionMockLocal() + try: + vehicle = db.query(Vehicle).filter(Vehicle.id == vehicle_id).first() + if not vehicle: + return None + return { + "id": int(vehicle.id), + "modelo": str(vehicle.modelo), + "categoria": str(vehicle.categoria), + "preco": float(vehicle.preco), + } + finally: + db.close() + + def _try_resolve_order_vehicle(self, message: str, user_id: int | None, payload: dict) -> dict | None: + vehicle_id = payload.get("vehicle_id") + if isinstance(vehicle_id, int) and vehicle_id > 0: + return self._load_vehicle_by_id(vehicle_id) + + stock_results = self._get_last_stock_results(user_id=user_id) + selected_from_model = self._match_vehicle_from_message_model(message=message, stock_results=stock_results) + if selected_from_model: + return selected_from_model + + selected_from_index = self._match_vehicle_from_message_index(message=message, stock_results=stock_results) + if selected_from_index: + return selected_from_index + + normalized_model = self._normalize_text(str(payload.get("modelo_veiculo") or "")) + if normalized_model: + matches = [ + item + for item in stock_results + if self._normalize_text(str(item.get("modelo") or "")) == normalized_model + ] + if len(matches) == 1: + return matches[0] + + return None + def _render_missing_order_fields_prompt(self, missing_fields: list[str]) -> str: labels = { "cpf": "o CPF do cliente", - "valor_veiculo": "o valor do veiculo (R$)", + "vehicle_id": "qual veiculo do estoque voce quer comprar", } itens = [f"- {labels[field]}" for field in missing_fields] return "Para realizar o pedido, preciso dos dados abaixo:\n" + "\n".join(itens) + def _render_vehicle_selection_from_stock_prompt(self, stock_results: list[dict]) -> str: + lines = ["Para realizar o pedido, escolha primeiro qual veiculo voce quer comprar:"] + for idx, item in enumerate(stock_results[:5], start=1): + lines.append( + f"- {idx}. [{item.get('id', 'N/A')}] {item.get('modelo', 'N/A')} " + f"({item.get('categoria', 'N/A')}) - R$ {float(item.get('preco', 0)):.2f}" + ) + lines.append("Pode responder com o numero da lista ou com o modelo do veiculo.") + return "\n".join(lines) + def _render_missing_cancel_order_fields_prompt(self, missing_fields: list[str]) -> str: labels = { "numero_pedido": "o numero do pedido (ex.: PED-20260305123456-ABC123)", @@ -144,7 +239,17 @@ class OrderFlowMixin: } draft["payload"].update(extracted) - self._try_prefill_order_value_from_memory(user_id=user_id, 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"]) + + resolved_vehicle = self._try_resolve_order_vehicle( + message=message, + user_id=user_id, + payload=draft["payload"], + ) + if resolved_vehicle: + self._store_selected_vehicle(user_id=user_id, vehicle=resolved_vehicle) + draft["payload"].update(self._vehicle_to_payload(resolved_vehicle)) cpf_value = draft["payload"].get("cpf") if cpf_value and not self._is_valid_cpf(str(cpf_value)): @@ -162,28 +267,24 @@ class OrderFlowMixin: self.state.set_entry("pending_order_drafts", user_id, draft) return "Para seguir com o pedido, preciso de um CPF valido. Pode me informar novamente?" - valor = draft["payload"].get("valor_veiculo") - if valor is not None: - try: - parsed = float(valor) - if parsed <= 0: - draft["payload"].pop("valor_veiculo", None) - else: - draft["payload"]["valor_veiculo"] = round(parsed, 2) - except (TypeError, ValueError): - draft["payload"].pop("valor_veiculo", None) - draft["expires_at"] = datetime.utcnow() + timedelta(minutes=PENDING_ORDER_DRAFT_TTL_MINUTES) self.state.set_entry("pending_order_drafts", user_id, draft) missing = [field for field in ORDER_REQUIRED_FIELDS if field not in draft["payload"]] if missing: + if "vehicle_id" in missing: + stock_results = self._get_last_stock_results(user_id=user_id) + if stock_results: + return self._render_vehicle_selection_from_stock_prompt(stock_results) return self._render_missing_order_fields_prompt(missing) try: tool_result = await self.registry.execute( "realizar_pedido", - draft["payload"], + { + "cpf": draft["payload"]["cpf"], + "vehicle_id": draft["payload"]["vehicle_id"], + }, user_id=user_id, ) except HTTPException as exc: @@ -204,10 +305,21 @@ class OrderFlowMixin: normalized_intents = self._normalize_intents(intents) draft = self.state.get_entry("pending_cancel_order_drafts", user_id, expire=True) + active_order_draft = self.state.get_entry("pending_order_drafts", user_id, expire=True) extracted = self._normalize_cancel_order_fields(extracted_fields) has_intent = normalized_intents.get("order_cancel", False) + if ( + draft is None + and active_order_draft is not None + and ( + not has_intent + or ("numero_pedido" not in extracted and "motivo" not in extracted) + ) + ): + return None + if ( draft and not has_intent diff --git a/app/services/orchestration/conversation_policy.py b/app/services/orchestration/conversation_policy.py index 9b48bab..7755a4a 100644 --- a/app/services/orchestration/conversation_policy.py +++ b/app/services/orchestration/conversation_policy.py @@ -82,8 +82,8 @@ class ConversationPolicy: order_fields = self.service.normalizer.normalize_order_fields(entities.get("order_fields")) if order_fields.get("cpf") and not seed.get("cpf"): seed["cpf"] = order_fields["cpf"] - if order_fields.get("valor_veiculo") and not seed.get("orcamento_max"): - seed["orcamento_max"] = int(round(order_fields["valor_veiculo"])) + if order_fields.get("modelo_veiculo") and not seed.get("modelo_veiculo"): + seed["modelo_veiculo"] = order_fields["modelo_veiculo"] return seed @@ -308,6 +308,57 @@ class ConversationPolicy: return self.contains_any_term(normalized, operational_terms) + # Distingue um comando global explicito de cancelamento do fluxo atual de um texto livre + # que deve ser consumido como dado do rascunho aberto. + def is_explicit_flow_cancel_message(self, message: str) -> bool: + normalized = self.service.normalizer.normalize_text(message).strip() + explicit_terms = { + "cancelar fluxo", + "cancela o fluxo", + "cancelar esse fluxo", + "cancela esse fluxo", + "cancelar fluxo atual", + "cancela o fluxo atual", + "encerrar fluxo", + "encerrar esse fluxo", + "parar fluxo", + "parar esse fluxo", + "abandonar fluxo", + "abandonar esse fluxo", + "desistir desse fluxo", + "desistir deste fluxo", + "desistir dessa compra", + "desistir desta compra", + } + return normalized in explicit_terms + + + # Evita que frases como "desisti" sejam tratadas como comando global quando o sistema + # esta justamente esperando o motivo do cancelamento. + def should_defer_flow_cancellation_control(self, message: str, user_id: int | None) -> bool: + if user_id is None or self.is_explicit_flow_cancel_message(message): + return False + + pending_cancel_order = self.service.state.get_entry("pending_cancel_order_drafts", user_id, expire=True) + if pending_cancel_order: + payload = pending_cancel_order.get("payload", {}) + if payload.get("numero_pedido") and not payload.get("motivo"): + free_text = str(message or "").strip() + if len(free_text) >= 4: + return True + + pending_review_management = self.service.state.get_entry("pending_review_management_drafts", user_id, expire=True) + if pending_review_management: + payload = pending_review_management.get("payload", {}) + action = pending_review_management.get("action", "cancel") + if action == "cancel" and payload.get("protocolo") and not payload.get("motivo"): + free_text = str(message or "").strip() + if len(free_text) >= 4 and not self.service._is_affirmative_message(free_text): + return True + + return False + + # Interpreta a resposta do usuário na etapa de seleção. def detect_selected_order_index(self, message: str, orders: list[dict]) -> tuple[int | None, bool]: normalized = self.strip_choice_message(self.service.normalizer.normalize_text(message)) @@ -649,6 +700,12 @@ class ConversationPolicy: memory = context.get("generic_memory", {}) if memory: summary.append(f"Memoria generica temporaria: {memory}.") + selected_vehicle = context.get("selected_vehicle") + if isinstance(selected_vehicle, dict) and selected_vehicle.get("modelo"): + summary.append(f"Veiculo selecionado para compra: {selected_vehicle.get('modelo')}.") + stock_results = context.get("last_stock_results") or [] + if isinstance(stock_results, list) and stock_results: + summary.append(f"Ultima consulta de estoque com {len(stock_results)} opcao(oes) disponivel(is).") order_queue = context.get("order_queue", []) if order_queue: summary.append(f"Fila de pedidos pendentes: {len(order_queue)}.") diff --git a/app/services/orchestration/conversation_state_store.py b/app/services/orchestration/conversation_state_store.py index 860d0a0..55f48b9 100644 --- a/app/services/orchestration/conversation_state_store.py +++ b/app/services/orchestration/conversation_state_store.py @@ -1,7 +1,9 @@ from datetime import datetime, timedelta +from app.services.orchestration.conversation_state_repository import ConversationStateRepository -class ConversationStateStore: + +class ConversationStateStore(ConversationStateRepository): def __init__(self) -> None: self.user_contexts: dict[int, dict] = {} self.pending_review_confirmations: dict[int, dict] = {} @@ -27,6 +29,8 @@ class ConversationStateStore: "order_queue": [], "pending_order_selection": None, "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, "expires_at": now + timedelta(minutes=ttl_minutes), } diff --git a/app/services/orchestration/entity_normalizer.py b/app/services/orchestration/entity_normalizer.py index c7e3906..f6821a9 100644 --- a/app/services/orchestration/entity_normalizer.py +++ b/app/services/orchestration/entity_normalizer.py @@ -158,12 +158,7 @@ class EntityNormalizer: def normalize_datetime_connector(self, text: str) -> str: compact = " ".join(str(text or "").strip().split()) - lowered = compact.lower() - marker = " as " - if marker in lowered: - index = lowered.index(marker) - return f"{compact[:index]} {compact[index + len(marker):]}".strip() - return compact + return re.sub(r"\s+[aàáâã]s\s+", " ", compact, flags=re.IGNORECASE).strip() def try_parse_iso_datetime(self, text: str) -> datetime | None: candidate = str(text or "").strip() @@ -366,9 +361,12 @@ class EntityNormalizer: cpf = self.normalize_cpf(data.get("cpf")) if cpf: extracted["cpf"] = cpf - value = self.normalize_positive_number(data.get("valor_veiculo")) - if value: - extracted["valor_veiculo"] = round(value, 2) + vehicle_id = self.normalize_positive_number(data.get("vehicle_id")) + if vehicle_id: + extracted["vehicle_id"] = int(round(vehicle_id)) + model = str(data.get("modelo_veiculo") or data.get("modelo") or "").strip(" ,.;") + if model: + extracted["modelo_veiculo"] = model.title() return extracted def normalize_cancel_order_fields(self, data) -> dict: diff --git a/app/services/orchestration/message_planner.py b/app/services/orchestration/message_planner.py index cb52ac2..83b7895 100644 --- a/app/services/orchestration/message_planner.py +++ b/app/services/orchestration/message_planner.py @@ -26,7 +26,7 @@ class MessagePlanner: ' "generic_memory": {"placa": null, "cpf": null, "orcamento_max": null, "perfil_veiculo": []},\n' ' "review_fields": {"placa": null, "data_hora": null, "modelo": null, "ano": null, "km": null, "revisao_previa_concessionaria": null},\n' ' "review_management_fields": {"protocolo": null, "nova_data_hora": null, "motivo": null},\n' - ' "order_fields": {"cpf": null, "valor_veiculo": null},\n' + ' "order_fields": {"cpf": null, "vehicle_id": null, "modelo_veiculo": null},\n' ' "cancel_order_fields": {"numero_pedido": null, "motivo": null},\n' ' "intents": {"review_schedule": false, "review_list": false, "review_cancel": false, "review_reschedule": false, "order_create": false, "order_cancel": false}\n' " }\n" @@ -94,7 +94,8 @@ class MessagePlanner: " },\n" ' "order_fields": {\n' ' "cpf": null,\n' - ' "valor_veiculo": null\n' + ' "vehicle_id": null,\n' + ' "modelo_veiculo": null\n' " },\n" ' "cancel_order_fields": {\n' ' "numero_pedido": null,\n' diff --git a/app/services/orchestration/orchestrator_config.py b/app/services/orchestration/orchestrator_config.py index 5787f83..3b24bb8 100644 --- a/app/services/orchestration/orchestrator_config.py +++ b/app/services/orchestration/orchestrator_config.py @@ -18,7 +18,7 @@ REVIEW_REQUIRED_FIELDS = ( ORDER_REQUIRED_FIELDS = ( "cpf", - "valor_veiculo", + "vehicle_id", ) CANCEL_ORDER_REQUIRED_FIELDS = ( diff --git a/app/services/orchestration/orquestrador_service.py b/app/services/orchestration/orquestrador_service.py index b9906b8..5c437b9 100644 --- a/app/services/orchestration/orquestrador_service.py +++ b/app/services/orchestration/orquestrador_service.py @@ -12,9 +12,10 @@ from app.services.orchestration.orchestrator_config import ( ) from app.services.ai.llm_service import LLMService from app.services.orchestration.conversation_policy import ConversationPolicy -from app.services.orchestration.conversation_state_store import ConversationStateStore +from app.services.orchestration.conversation_state_repository import ConversationStateRepository from app.services.orchestration.entity_normalizer import EntityNormalizer from app.services.orchestration.message_planner import MessagePlanner +from app.services.orchestration.state_repository_factory import get_conversation_state_repository from app.services.flows.order_flow import OrderFlowMixin from app.services.orchestration.prompt_builders import ( build_force_tool_prompt, @@ -29,11 +30,13 @@ logger = logging.getLogger(__name__) class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): - default_state_repository = ConversationStateStore() - - def __init__(self, db: Session): + def __init__( + self, + db: Session, + state_repository: ConversationStateRepository | None = None, + ): """Inicializa servicos de LLM e registro de tools para a sessao atual.""" - self.state = self.default_state_repository + self.state = state_repository or get_conversation_state_repository() self.llm = LLMService() self.normalizer = EntityNormalizer() self.planner = MessagePlanner(llm=self.llm, normalizer=self.normalizer) @@ -218,6 +221,11 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): user_id=user_id, ) return await finish(self._http_exception_detail(exc), queue_notice=queue_notice) + self._capture_tool_result_context( + tool_name=tool_name, + tool_result=tool_result, + user_id=user_id, + ) if self._should_use_deterministic_response(tool_name): return await finish( @@ -271,6 +279,11 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): tool_call = llm_result.get("tool_call") or {} tool_name = tool_call.get("name") if tool_name in ORCHESTRATION_CONTROL_TOOLS: + if ( + tool_name == "cancelar_fluxo_atual" + and self.policy.should_defer_flow_cancellation_control(message=message, user_id=user_id) + ): + return None try: tool_result = await self.tool_executor.execute( tool_name, @@ -306,6 +319,11 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): forced_tool_name = forced_tool_call.get("name") if forced_tool_name not in ORCHESTRATION_CONTROL_TOOLS: return None + if ( + forced_tool_name == "cancelar_fluxo_atual" + and self.policy.should_defer_flow_cancellation_control(message=message, user_id=user_id) + ): + return None try: tool_result = await self.tool_executor.execute( @@ -346,6 +364,8 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): context["order_queue"] = [] context["pending_order_selection"] = None context["pending_switch"] = None + context["last_stock_results"] = [] + context["selected_vehicle"] = None def _clear_pending_order_navigation(self, user_id: int | None) -> int: context = self._get_user_context(user_id) @@ -499,6 +519,40 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): context["generic_memory"].update(fields) context.setdefault("shared_memory", {}).update(fields) + def _capture_tool_result_context( + self, + tool_name: str, + tool_result, + user_id: int | None, + ) -> None: + context = self._get_user_context(user_id) + if not context: + return + if tool_name != "consultar_estoque" or not isinstance(tool_result, list): + return + + sanitized: list[dict] = [] + for item in tool_result[:20]: + if not isinstance(item, dict): + continue + try: + vehicle_id = int(item.get("id")) + preco = float(item.get("preco") or 0) + except (TypeError, ValueError): + continue + sanitized.append( + { + "id": vehicle_id, + "modelo": str(item.get("modelo") or "").strip(), + "categoria": str(item.get("categoria") or "").strip(), + "preco": preco, + } + ) + + context["last_stock_results"] = sanitized + if sanitized: + context["selected_vehicle"] = None + def _new_tab_memory(self, user_id: int | None) -> dict: context = self._get_user_context(user_id) if not context: diff --git a/app/services/orchestration/response_formatter.py b/app/services/orchestration/response_formatter.py index 05714bb..0b38161 100644 --- a/app/services/orchestration/response_formatter.py +++ b/app/services/orchestration/response_formatter.py @@ -28,7 +28,8 @@ def fallback_format_tool_result(tool_name: str, tool_result: Any) -> str: 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}") + codigo = item.get("id", "N/A") + linhas.append(f"{idx}. [{codigo}] {modelo} ({categoria}) - {preco}") restantes = len(tool_result) - 10 if restantes > 0: linhas.append(f"... e mais {restantes} veiculo(s).") @@ -46,7 +47,8 @@ def fallback_format_tool_result(tool_name: str, tool_result: Any) -> str: if tool_name == "realizar_pedido" and isinstance(tool_result, dict): numero = tool_result.get("numero_pedido", "N/A") valor = format_currency_br(tool_result.get("valor_veiculo")) - return f"Pedido criado com sucesso.\nNumero: {numero}\nValor: {valor}" + modelo = tool_result.get("modelo_veiculo", "N/A") + return f"Pedido criado com sucesso.\nNumero: {numero}\nVeiculo: {modelo}\nValor: {valor}" if tool_name == "agendar_revisao" and isinstance(tool_result, dict): placa = tool_result.get("placa", "N/A") diff --git a/app/services/tools/handlers.py b/app/services/tools/handlers.py index 0ce9947..377a977 100644 --- a/app/services/tools/handlers.py +++ b/app/services/tools/handlers.py @@ -6,6 +6,8 @@ from typing import Any, Dict, List, Optional from fastapi import HTTPException from sqlalchemy import func +from sqlalchemy.exc import OperationalError, SQLAlchemyError +from sqlalchemy.sql import text from app.db.mock_database import SessionMockLocal from app.db.mock_models import Customer, Order, ReviewSchedule, User, Vehicle @@ -200,7 +202,7 @@ def _parse_data_hora_revisao(value: str) -> datetime: if not text: raise ValueError("data_hora vazia") - normalized = re.sub(r"\s+[aA]s\s+", " ", text) + normalized = re.sub(r"\s+[aàáâã]s\s+", " ", text, flags=re.IGNORECASE) iso_candidates = [text, normalized] for candidate in iso_candidates: try: @@ -627,23 +629,30 @@ async def cancelar_pedido(numero_pedido: str, motivo: str, user_id: Optional[int db.close() -async def realizar_pedido(cpf: str, valor_veiculo: float, user_id: Optional[int] = None) -> Dict[str, Any]: - """Cria um novo pedido de compra quando o cliente estiver aprovado para o valor informado.""" +async def realizar_pedido(cpf: str, vehicle_id: int, user_id: Optional[int] = None) -> Dict[str, Any]: + """Cria um novo pedido de compra quando o cliente estiver aprovado para o veiculo selecionado.""" cpf_norm = normalize_cpf(cpf) - await hydrate_mock_customer_from_cpf(cpf=cpf_norm, user_id=user_id) - avaliacao = await validar_cliente_venda(cpf=cpf_norm, valor_veiculo=valor_veiculo) - if not avaliacao.get("aprovado"): - raise HTTPException( - status_code=400, - detail=( - "Cliente nao aprovado para este valor. " - f"Limite disponivel: R$ {avaliacao.get('limite_credito', 0):.2f}." - ), - ) - - numero_pedido = f"PED-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{uuid4().hex[:6].upper()}" db = SessionMockLocal() try: + vehicle = db.query(Vehicle).filter(Vehicle.id == vehicle_id).first() + if not vehicle: + raise HTTPException(status_code=404, detail="Veiculo nao encontrado no estoque.") + + valor_veiculo = float(vehicle.preco) + modelo_veiculo = str(vehicle.modelo) + + await hydrate_mock_customer_from_cpf(cpf=cpf_norm, user_id=user_id) + avaliacao = await validar_cliente_venda(cpf=cpf_norm, valor_veiculo=valor_veiculo) + if not avaliacao.get("aprovado"): + raise HTTPException( + status_code=400, + detail=( + "Cliente nao aprovado para este valor. " + f"Limite disponivel: R$ {avaliacao.get('limite_credito', 0):.2f}." + ), + ) + + numero_pedido = f"PED-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{uuid4().hex[:6].upper()}" if user_id is not None: user = db.query(User).filter(User.id == user_id).first() if user and user.cpf != cpf_norm: @@ -653,18 +662,59 @@ async def realizar_pedido(cpf: str, valor_veiculo: float, user_id: Optional[int] numero_pedido=numero_pedido, user_id=user_id, cpf=cpf_norm, + vehicle_id=vehicle.id, + modelo_veiculo=modelo_veiculo, + valor_veiculo=valor_veiculo, status="Ativo", ) db.add(pedido) - db.commit() - db.refresh(pedido) + try: + db.commit() + db.refresh(pedido) + except (OperationalError, SQLAlchemyError) as exc: + db.rollback() + lowered = str(exc).lower() + legacy_schema_issue = ( + "unknown column" in lowered + or "invalid column" in lowered + or "has no column named" in lowered + or "column count doesn't match" in lowered + ) + if not legacy_schema_issue: + raise + + db.execute( + text( + "INSERT INTO orders (numero_pedido, user_id, cpf, status) " + "VALUES (:numero_pedido, :user_id, :cpf, :status)" + ), + { + "numero_pedido": numero_pedido, + "user_id": user_id, + "cpf": cpf_norm, + "status": "Ativo", + }, + ) + db.commit() + return { + "numero_pedido": numero_pedido, + "user_id": user_id, + "cpf": cpf_norm, + "vehicle_id": vehicle.id, + "modelo_veiculo": modelo_veiculo, + "status": "Ativo", + "valor_veiculo": valor_veiculo, + "aprovado_credito": True, + } return { "numero_pedido": pedido.numero_pedido, "user_id": pedido.user_id, "cpf": pedido.cpf, + "vehicle_id": pedido.vehicle_id, + "modelo_veiculo": pedido.modelo_veiculo, "status": pedido.status, - "valor_veiculo": valor_veiculo, + "valor_veiculo": pedido.valor_veiculo, "aprovado_credito": True, } finally: diff --git a/tests/test_conversation_adjustments.py b/tests/test_conversation_adjustments.py new file mode 100644 index 0000000..bc7f015 --- /dev/null +++ b/tests/test_conversation_adjustments.py @@ -0,0 +1,315 @@ +import os +import unittest +from datetime import datetime, timedelta +from unittest.mock import patch + +os.environ.setdefault("DEBUG", "false") + +from app.services.flows.order_flow import OrderFlowMixin +from app.services.orchestration.conversation_policy import ConversationPolicy +from app.services.orchestration.entity_normalizer import EntityNormalizer +from app.services.tools.handlers import _parse_data_hora_revisao + + +class FakeState: + def __init__(self, entries=None, contexts=None): + self.entries = entries or {} + self.contexts = contexts or {} + + def get_entry(self, bucket: str, user_id: int | None, *, expire: bool = False): + if user_id is None: + return None + return self.entries.get(bucket, {}).get(user_id) + + def set_entry(self, bucket: str, user_id: int | None, value: dict): + if user_id is None: + return + self.entries.setdefault(bucket, {})[user_id] = value + + def pop_entry(self, bucket: str, user_id: int | None): + if user_id is None: + return None + return self.entries.get(bucket, {}).pop(user_id, None) + + def get_user_context(self, user_id: int | None): + if user_id is None: + return None + return self.contexts.get(user_id) + + +class FakeService: + def __init__(self, state): + self.state = state + self.normalizer = EntityNormalizer() + + def _is_affirmative_message(self, text: str) -> bool: + normalized = self.normalizer.normalize_text(text).strip().rstrip(".!?,;:") + return normalized in {"sim", "pode", "ok", "confirmo", "aceito", "fechado", "pode sim", "tenho", "tenho sim"} + + def _get_user_context(self, user_id: int | None): + return self.state.get_user_context(user_id) + + +class FakeRegistry: + def __init__(self): + self.calls = [] + + async def execute(self, tool_name: str, arguments: dict, user_id: int | None = None): + self.calls.append((tool_name, arguments, user_id)) + if tool_name == "realizar_pedido": + vehicle_map = { + 1: ("Honda Civic 2021", 51524.0), + 2: ("Toyota Corolla 2020", 58476.0), + } + modelo_veiculo, valor_veiculo = vehicle_map[arguments["vehicle_id"]] + return { + "numero_pedido": "PED-TESTE-123", + "status": "Ativo", + "modelo_veiculo": modelo_veiculo, + "valor_veiculo": valor_veiculo, + } + return { + "numero_pedido": arguments["numero_pedido"], + "status": "Cancelado", + "motivo": arguments["motivo"], + } + + +class OrderFlowHarness(OrderFlowMixin): + def __init__(self, state, registry): + self.state = state + self.registry = registry + self.normalizer = EntityNormalizer() + + def _get_user_context(self, user_id: int | None): + return self.state.get_user_context(user_id) + + def _normalize_intents(self, data) -> dict: + return self.normalizer.normalize_intents(data) + + def _normalize_cancel_order_fields(self, data) -> dict: + return self.normalizer.normalize_cancel_order_fields(data) + + def _normalize_order_fields(self, data) -> dict: + return self.normalizer.normalize_order_fields(data) + + def _normalize_text(self, text: str) -> str: + return self.normalizer.normalize_text(text) + + def _http_exception_detail(self, exc) -> str: + return str(exc) + + def _fallback_format_tool_result(self, tool_name: str, tool_result) -> str: + if tool_name == "realizar_pedido": + return ( + f"Pedido criado com sucesso.\n" + f"Numero: {tool_result['numero_pedido']}\n" + f"Veiculo: {tool_result['modelo_veiculo']}\n" + f"Valor: R$ {tool_result['valor_veiculo']:.2f}" + ) + return ( + f"Pedido {tool_result['numero_pedido']} atualizado.\n" + f"Status: {tool_result['status']}\n" + f"Motivo: {tool_result['motivo']}" + ) + + def _try_prefill_order_cpf_from_user_profile(self, user_id: int | None, payload: dict) -> None: + return None + + def _load_vehicle_by_id(self, vehicle_id: int) -> dict | None: + for context in self.state.contexts.values(): + for item in context.get("last_stock_results", []): + if int(item["id"]) == int(vehicle_id): + return dict(item) + return None + + +class ConversationAdjustmentsTests(unittest.TestCase): + def test_defer_flow_cancel_when_order_cancel_draft_waits_for_reason(self): + state = FakeState( + entries={ + "pending_cancel_order_drafts": { + 7: { + "payload": {"numero_pedido": "PED-20260305120000-ABC123"}, + "expires_at": datetime.utcnow() + timedelta(minutes=30), + } + } + } + ) + policy = ConversationPolicy(service=FakeService(state)) + + self.assertTrue(policy.should_defer_flow_cancellation_control("desisti", user_id=7)) + self.assertFalse(policy.should_defer_flow_cancellation_control("cancelar fluxo atual", user_id=7)) + + def test_normalize_datetime_connector_accepts_as_com_acento(self): + normalizer = EntityNormalizer() + + self.assertEqual( + normalizer.normalize_datetime_connector("10/03/2026 às 09:00"), + "10/03/2026 09:00", + ) + + def test_parse_review_datetime_accepts_as_com_acento(self): + parsed = _parse_data_hora_revisao("10/03/2026 às 09:00") + + self.assertEqual(parsed, datetime(2026, 3, 10, 9, 0)) + + +class CancelOrderFlowTests(unittest.IsolatedAsyncioTestCase): + async def test_cancel_order_flow_consumes_free_text_reason(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="desisti", + user_id=42, + extracted_fields={}, + intents={}, + ) + + 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"], "desisti") + 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={ + "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="ok", + user_id=42, + extracted_fields={}, + intents={}, + ) + + self.assertEqual(registry.calls, []) + self.assertIn("o motivo do cancelamento", response) + self.assertIsNotNone(state.get_entry("pending_cancel_order_drafts", 42)) + + async def test_cancel_order_flow_does_not_override_active_order_creation_draft(self): + state = FakeState( + entries={ + "pending_order_drafts": { + 42: { + "payload": {}, + "expires_at": datetime.utcnow() + timedelta(minutes=30), + } + } + } + ) + registry = FakeRegistry() + flow = OrderFlowHarness(state=state, registry=registry) + + response = await flow._try_collect_and_cancel_order( + message="2", + user_id=42, + extracted_fields={}, + intents={"order_cancel": True}, + ) + + self.assertIsNone(response) + self.assertEqual(registry.calls, []) + + +class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase): + async def test_order_flow_requests_vehicle_selection_from_last_stock_results(self): + state = FakeState( + contexts={ + 10: { + "generic_memory": {}, + "last_stock_results": [ + {"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 51524.0}, + {"id": 2, "modelo": "Toyota Corolla 2020", "categoria": "hatch", "preco": 58476.0}, + ], + "selected_vehicle": None, + } + } + ) + registry = FakeRegistry() + flow = OrderFlowHarness(state=state, registry=registry) + + response = await flow._try_collect_and_create_order( + message="Quero fazer um pedido", + user_id=10, + extracted_fields={}, + intents={"order_create": True}, + ) + + self.assertIn("escolha primeiro qual veiculo", response.lower()) + self.assertIn("Honda Civic 2021", response) + self.assertEqual(registry.calls, []) + + async def test_order_flow_creates_order_with_selected_vehicle_from_list_index(self): + state = FakeState( + entries={ + "pending_order_drafts": { + 10: { + "payload": {"cpf": "12345678909"}, + "expires_at": datetime.utcnow() + timedelta(minutes=30), + } + } + }, + contexts={ + 10: { + "generic_memory": {"cpf": "12345678909"}, + "last_stock_results": [ + {"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 51524.0}, + {"id": 2, "modelo": "Toyota Corolla 2020", "categoria": "hatch", "preco": 58476.0}, + ], + "selected_vehicle": None, + } + }, + ) + registry = FakeRegistry() + 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="2", + user_id=10, + extracted_fields={}, + intents={}, + ) + + self.assertEqual(len(registry.calls), 1) + tool_name, arguments, tool_user_id = registry.calls[0] + self.assertEqual(tool_name, "realizar_pedido") + self.assertEqual(tool_user_id, 10) + self.assertEqual(arguments["vehicle_id"], 2) + self.assertEqual(arguments["cpf"], "12345678909") + self.assertIn("Veiculo: Toyota Corolla 2020", response) + + +if __name__ == "__main__": + unittest.main()