diff --git a/app/services/flows/order_flow.py b/app/services/flows/order_flow.py index c7c6f88..3049cb8 100644 --- a/app/services/flows/order_flow.py +++ b/app/services/flows/order_flow.py @@ -5,6 +5,7 @@ from fastapi import HTTPException from app.db.mock_database import SessionMockLocal from app.db.mock_models import User, Vehicle +from app.services.orchestration.technical_normalizer import is_valid_cpf from app.services.orchestration.orchestrator_config import ( CANCEL_ORDER_REQUIRED_FIELDS, ORDER_REQUIRED_FIELDS, @@ -14,7 +15,12 @@ from app.services.orchestration.orchestrator_config import ( from app.services.user.mock_customer_service import hydrate_mock_customer_from_cpf +# Esse mixin cuida dos fluxos de venda: +# criacao de pedido, selecao de veiculo e cancelamento. class OrderFlowMixin: + def _decision_intent(self, turn_decision: dict | None) -> str: + return str((turn_decision or {}).get("intent") or "").strip().lower() + def _has_explicit_order_request(self, message: str) -> bool: normalized = self._normalize_text(message).strip() order_terms = { @@ -30,25 +36,25 @@ class OrderFlowMixin: } return any(term in normalized for term in order_terms) - def _is_valid_cpf(self, cpf: str) -> bool: - digits = re.sub(r"\D", "", cpf or "") - if len(digits) != 11: - return False - if digits == digits[0] * 11: - return False - - numbers = [int(d) for d in digits] - - sum_first = sum(n * w for n, w in zip(numbers[:9], range(10, 1, -1))) - first_digit = 11 - (sum_first % 11) - first_digit = 0 if first_digit >= 10 else first_digit - if first_digit != numbers[9]: - return False + def _has_stock_listing_request(self, message: str, turn_decision: dict | None = None) -> bool: + if self._decision_intent(turn_decision) == "inventory_search": + return True + normalized = self._normalize_text(message).strip() + stock_terms = { + "estoque", + "listar", + "liste", + "mostrar", + "mostre", + "ver carros", + "ver veiculos", + "opcoes", + "modelos", + } + return any(term in normalized for term in stock_terms) - sum_second = sum(n * w for n, w in zip(numbers[:10], range(11, 1, -1))) - second_digit = 11 - (sum_second % 11) - second_digit = 0 if second_digit >= 10 else second_digit - return second_digit == numbers[10] + def _is_valid_cpf(self, cpf: str) -> bool: + return is_valid_cpf(cpf) def _try_prefill_order_cpf_from_memory(self, user_id: int | None, payload: dict) -> None: if user_id is None or payload.get("cpf"): @@ -88,6 +94,31 @@ class OrderFlowMixin: selected_vehicle = context.get("selected_vehicle") return dict(selected_vehicle) if isinstance(selected_vehicle, dict) else None + def _remember_stock_results(self, user_id: int | None, stock_results: list[dict] | None) -> None: + context = self._get_user_context(user_id) + if not context: + return + sanitized: list[dict] = [] + for item in stock_results or []: + 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 _store_selected_vehicle(self, user_id: int | None, vehicle: dict | None) -> None: if user_id is None: return @@ -110,6 +141,26 @@ class OrderFlowMixin: if selected_vehicle: payload.update(self._vehicle_to_payload(selected_vehicle)) + def _build_stock_lookup_arguments(self, user_id: int | None, payload: dict | None = None) -> dict: + context = self._get_user_context(user_id) + generic_memory = context.get("generic_memory", {}) if isinstance(context, dict) else {} + source = payload if isinstance(payload, dict) else {} + budget = generic_memory.get("orcamento_max") + if budget is None: + budget = source.get("valor_veiculo") + + arguments: dict = {} + if isinstance(budget, (int, float)) and float(budget) > 0: + arguments["preco_max"] = float(budget) + + perfil = generic_memory.get("perfil_veiculo") + if isinstance(perfil, list) and perfil: + arguments["categoria"] = str(perfil[0]).strip().lower() + + arguments["limite"] = 5 + arguments["ordenar_preco"] = "asc" + return arguments + 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: @@ -146,6 +197,8 @@ class OrderFlowMixin: db.close() def _try_resolve_order_vehicle(self, message: str, user_id: int | None, payload: dict) -> dict | None: + # Primeiro tenta um vehicle_id explicito; depois tenta casar + # a resposta do usuario com a ultima lista de estoque mostrada. vehicle_id = payload.get("vehicle_id") if isinstance(vehicle_id, int) and vehicle_id > 0: return self._load_vehicle_by_id(vehicle_id) @@ -189,6 +242,32 @@ class OrderFlowMixin: lines.append("Pode responder com o numero da lista ou com o modelo do veiculo.") return "\n".join(lines) + async def _try_list_stock_for_order_selection( + self, + message: str, + user_id: int | None, + payload: dict, + turn_decision: dict | None = None, + ) -> str | None: + if user_id is None or not self._has_stock_listing_request(message, turn_decision=turn_decision): + return None + + arguments = self._build_stock_lookup_arguments(user_id=user_id, payload=payload) + if "preco_max" not in arguments and "categoria" not in arguments: + return None + + try: + tool_result = await self.registry.execute( + "consultar_estoque", + arguments, + user_id=user_id, + ) + except HTTPException as exc: + return self._http_exception_detail(exc) + + self._remember_stock_results(user_id=user_id, stock_results=tool_result if isinstance(tool_result, list) else []) + return self._fallback_format_tool_result("consultar_estoque", tool_result) + def _render_missing_cancel_order_fields_prompt(self, missing_fields: list[str]) -> str: labels = { "numero_pedido": "o numero do pedido (ex.: PED-20260305123456-ABC123)", @@ -203,6 +282,7 @@ class OrderFlowMixin: user_id: int | None, extracted_fields: dict | None = None, intents: dict | None = None, + turn_decision: dict | None = None, ) -> str | None: if user_id is None: return None @@ -211,14 +291,22 @@ class OrderFlowMixin: draft = self.state.get_entry("pending_order_drafts", user_id, expire=True) extracted = self._normalize_order_fields(extracted_fields) - has_intent = normalized_intents.get("order_create", False) + decision_intent = self._decision_intent(turn_decision) + has_intent = decision_intent == "order_create" or normalized_intents.get("order_create", False) explicit_order_request = self._has_explicit_order_request(message) if ( draft and not has_intent and ( - normalized_intents.get("review_schedule", False) + decision_intent in { + "review_schedule", + "review_list", + "review_cancel", + "review_reschedule", + "order_cancel", + } + or normalized_intents.get("review_schedule", False) or normalized_intents.get("review_list", False) or normalized_intents.get("review_cancel", False) or normalized_intents.get("review_reschedule", False) @@ -229,7 +317,7 @@ class OrderFlowMixin: self.state.pop_entry("pending_order_drafts", user_id) return None - if draft is None and not (has_intent and explicit_order_request): + if draft is None and not has_intent and not explicit_order_request: return None if draft is None: @@ -248,6 +336,8 @@ class OrderFlowMixin: payload=draft["payload"], ) if resolved_vehicle: + # Mantem a selecao no estado para que o usuario informe + # o CPF depois sem perder o veiculo escolhido. self._store_selected_vehicle(user_id=user_id, vehicle=resolved_vehicle) draft["payload"].update(self._vehicle_to_payload(resolved_vehicle)) @@ -273,6 +363,14 @@ class OrderFlowMixin: missing = [field for field in ORDER_REQUIRED_FIELDS if field not in draft["payload"]] if missing: if "vehicle_id" in missing: + stock_response = await self._try_list_stock_for_order_selection( + message=message, + user_id=user_id, + payload=draft["payload"], + turn_decision=turn_decision, + ) + if stock_response: + return stock_response stock_results = self._get_last_stock_results(user_id=user_id) if stock_results: return self._render_vehicle_selection_from_stock_prompt(stock_results) @@ -299,6 +397,7 @@ class OrderFlowMixin: user_id: int | None, extracted_fields: dict | None = None, intents: dict | None = None, + turn_decision: dict | None = None, ) -> str | None: if user_id is None: return None @@ -308,7 +407,8 @@ class OrderFlowMixin: 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) + decision_intent = self._decision_intent(turn_decision) + has_intent = decision_intent == "order_cancel" or normalized_intents.get("order_cancel", False) if ( draft is None @@ -324,7 +424,14 @@ class OrderFlowMixin: draft and not has_intent and ( - normalized_intents.get("review_schedule", False) + decision_intent in { + "review_schedule", + "review_list", + "review_cancel", + "review_reschedule", + "order_create", + } + or normalized_intents.get("review_schedule", False) or normalized_intents.get("review_list", False) or normalized_intents.get("review_cancel", False) or normalized_intents.get("review_reschedule", False) @@ -349,6 +456,8 @@ class OrderFlowMixin: and draft["payload"].get("numero_pedido") and not has_intent ): + # Quando o pedido ja foi identificado, um texto livre curto + # e tratado como motivo do cancelamento. free_text = (message or "").strip() if free_text and len(free_text) >= 4: extracted["motivo"] = free_text diff --git a/app/services/orchestration/response_formatter.py b/app/services/orchestration/response_formatter.py index 0b38161..09b2219 100644 --- a/app/services/orchestration/response_formatter.py +++ b/app/services/orchestration/response_formatter.py @@ -2,6 +2,8 @@ from datetime import datetime from typing import Any +# Formatter deterministico usado como fallback seguro quando a resposta +# final nao deve depender do LLM. def format_datetime_for_chat(value: str) -> str: try: dt = datetime.fromisoformat((value or "").replace("Z", "+00:00")) @@ -20,6 +22,7 @@ def format_currency_br(value: Any) -> str: def fallback_format_tool_result(tool_name: str, tool_result: Any) -> str: + # Cada tool recebe um formato enxuto e previsivel para o usuario final. if tool_name == "consultar_estoque" and isinstance(tool_result, list): if not tool_result: return "Nao encontrei nenhum veiculo com os criterios informados." @@ -48,7 +51,16 @@ def fallback_format_tool_result(tool_name: str, tool_result: Any) -> str: numero = tool_result.get("numero_pedido", "N/A") valor = format_currency_br(tool_result.get("valor_veiculo")) modelo = tool_result.get("modelo_veiculo", "N/A") - return f"Pedido criado com sucesso.\nNumero: {numero}\nVeiculo: {modelo}\nValor: {valor}" + status_veiculo = str(tool_result.get("status_veiculo") or "").strip() + lines = [ + "Pedido criado com sucesso.", + f"Numero: {numero}", + f"Veiculo: {modelo}", + f"Valor: {valor}", + ] + if status_veiculo: + lines.append(f"Status do veiculo: {status_veiculo}") + return "\n".join(lines) 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 377a977..44d274e 100644 --- a/app/services/tools/handlers.py +++ b/app/services/tools/handlers.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta, timezone import hashlib +import logging import re from uuid import uuid4 from typing import Any, Dict, List, Optional @@ -11,15 +12,13 @@ from sqlalchemy.sql import text from app.db.mock_database import SessionMockLocal from app.db.mock_models import Customer, Order, ReviewSchedule, User, Vehicle +from app.services.orchestration.technical_normalizer import normalize_cpf from app.services.user.mock_customer_service import hydrate_mock_customer_from_cpf # Nesse arquivo eu faço a normalização dos dados para persisti-los no DB +# Tambem ficam as tools mock que simulam regras de negocio do dominio. - -def normalize_cpf(value: str) -> str: - """Normaliza CPF removendo qualquer caractere nao numerico.""" - return re.sub(r"\D", "", value or "") - +logger = logging.getLogger(__name__) def _parse_float(value: Any, default: float = 0.0) -> float: """Converte entradas numericas/textuais para float com fallback padrao.""" @@ -35,6 +34,17 @@ def _parse_float(value: Any, default: float = 0.0) -> float: return default +def _is_legacy_schema_issue(exc: Exception) -> bool: + lowered = str(exc).lower() + return ( + "unknown column" in lowered + or "invalid column" in lowered + or "has no column named" in lowered + or "no such column" in lowered + or "column count doesn't match" in lowered + ) + + def _stable_int(seed_text: str) -> int: """Gera inteiro deterministico a partir de um texto usando hash SHA-256.""" digest = hashlib.sha256(seed_text.encode("utf-8")).hexdigest() @@ -107,7 +117,27 @@ async def consultar_estoque( """Consulta veiculos no estoque com filtros opcionais e ordenacao por preco.""" db = SessionMockLocal() try: + reserved_vehicle_ids = set() + try: + reserved_vehicle_ids = { + int(vehicle_id) + for (vehicle_id,) in ( + db.query(Order.vehicle_id) + .filter(Order.vehicle_id.isnot(None)) + .filter(Order.status != "Cancelado") + .all() + ) + if vehicle_id is not None + } + except (OperationalError, SQLAlchemyError) as exc: + if not _is_legacy_schema_issue(exc): + raise + db.rollback() + logger.warning("Schema legado sem vehicle_id em orders; estoque nao filtrara reservas.") + query = db.query(Vehicle) + if reserved_vehicle_ids: + query = query.filter(~Vehicle.id.in_(reserved_vehicle_ids)) if preco_max is not None: query = query.filter(Vehicle.preco <= preco_max) @@ -638,6 +668,25 @@ async def realizar_pedido(cpf: str, vehicle_id: int, user_id: Optional[int] = No if not vehicle: raise HTTPException(status_code=404, detail="Veiculo nao encontrado no estoque.") + existing_order = None + try: + existing_order = ( + db.query(Order) + .filter(Order.vehicle_id == vehicle_id) + .filter(Order.status != "Cancelado") + .first() + ) + except (OperationalError, SQLAlchemyError) as exc: + if not _is_legacy_schema_issue(exc): + raise + db.rollback() + logger.warning("Schema legado sem vehicle_id em orders; reserva exclusiva de veiculo desativada.") + if existing_order: + raise HTTPException( + status_code=409, + detail="Este veiculo ja esta reservado e nao aparece mais no estoque disponivel.", + ) + valor_veiculo = float(vehicle.preco) modelo_veiculo = str(vehicle.modelo) @@ -658,6 +707,8 @@ async def realizar_pedido(cpf: str, vehicle_id: int, user_id: Optional[int] = No if user and user.cpf != cpf_norm: user.cpf = cpf_norm + # Tenta gravar no schema novo; se a tabela ainda estiver + # no formato legado, cai para um insert minimo compativel. pedido = Order( numero_pedido=numero_pedido, user_id=user_id, @@ -673,13 +724,7 @@ async def realizar_pedido(cpf: str, vehicle_id: int, user_id: Optional[int] = No 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 - ) + legacy_schema_issue = _is_legacy_schema_issue(exc) if not legacy_schema_issue: raise @@ -703,6 +748,7 @@ async def realizar_pedido(cpf: str, vehicle_id: int, user_id: Optional[int] = No "vehicle_id": vehicle.id, "modelo_veiculo": modelo_veiculo, "status": "Ativo", + "status_veiculo": "Reservado", "valor_veiculo": valor_veiculo, "aprovado_credito": True, } @@ -714,6 +760,7 @@ async def realizar_pedido(cpf: str, vehicle_id: int, user_id: Optional[int] = No "vehicle_id": pedido.vehicle_id, "modelo_veiculo": pedido.modelo_veiculo, "status": pedido.status, + "status_veiculo": "Reservado", "valor_veiculo": pedido.valor_veiculo, "aprovado_credito": True, } diff --git a/tests/test_conversation_adjustments.py b/tests/test_conversation_adjustments.py index bc7f015..ee9c90d 100644 --- a/tests/test_conversation_adjustments.py +++ b/tests/test_conversation_adjustments.py @@ -56,6 +56,11 @@ class FakeRegistry: 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 == "consultar_estoque": + return [ + {"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 48500.0}, + {"id": 2, "modelo": "Toyota Yaris 2020", "categoria": "hatch", "preco": 49900.0}, + ] if tool_name == "realizar_pedido": vehicle_map = { 1: ("Honda Civic 2021", 51524.0), @@ -100,6 +105,11 @@ class OrderFlowHarness(OrderFlowMixin): return str(exc) def _fallback_format_tool_result(self, tool_name: str, tool_result) -> str: + if tool_name == "consultar_estoque": + lines = [f"Encontrei {len(tool_result)} veiculo(s):"] + for idx, item in enumerate(tool_result, start=1): + lines.append(f"{idx}. [{item['id']}] {item['modelo']} ({item['categoria']}) - R$ {item['preco']:.2f}") + return "\n".join(lines) if tool_name == "realizar_pedido": return ( f"Pedido criado com sucesso.\n" @@ -156,6 +166,22 @@ class ConversationAdjustmentsTests(unittest.TestCase): class CancelOrderFlowTests(unittest.IsolatedAsyncioTestCase): + async def test_cancel_order_flow_accepts_turn_decision_without_legacy_intents(self): + state = FakeState() + registry = FakeRegistry() + flow = OrderFlowHarness(state=state, registry=registry) + + response = await flow._try_collect_and_cancel_order( + message="cancelar pedido PED-20260305120000-ABC123", + user_id=42, + extracted_fields={"numero_pedido": "PED-20260305120000-ABC123"}, + intents={}, + turn_decision={"intent": "order_cancel", "domain": "sales", "action": "collect_order_cancel"}, + ) + + self.assertIn("o motivo do cancelamento", response) + self.assertIsNotNone(state.get_entry("pending_cancel_order_drafts", 42)) + async def test_cancel_order_flow_consumes_free_text_reason(self): state = FakeState( entries={ @@ -237,6 +263,138 @@ class CancelOrderFlowTests(unittest.IsolatedAsyncioTestCase): class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase): + async def test_order_flow_auto_lists_stock_on_first_purchase_message_when_budget_exists(self): + state = FakeState( + contexts={ + 10: { + "generic_memory": {"cpf": "12345678909", "orcamento_max": 50000}, + "last_stock_results": [], + "selected_vehicle": None, + } + } + ) + registry = FakeRegistry() + flow = OrderFlowHarness(state=state, registry=registry) + + response = await flow._try_collect_and_create_order( + message="Quero comprar um carro de 50 mil, meu CPF e 12345678909", + user_id=10, + extracted_fields={"cpf": "12345678909"}, + intents={}, + turn_decision={"intent": "order_create", "domain": "sales", "action": "collect_order_create"}, + ) + + self.assertIn("qual veiculo do estoque voce quer comprar", response.lower()) + + async def test_order_flow_lists_stock_from_budget_when_vehicle_is_missing(self): + state = FakeState( + entries={ + "pending_order_drafts": { + 10: { + "payload": {"cpf": "12345678909"}, + "expires_at": datetime.utcnow() + timedelta(minutes=30), + } + } + }, + contexts={ + 10: { + "generic_memory": {"cpf": "12345678909", "orcamento_max": 50000}, + "last_stock_results": [], + "selected_vehicle": None, + } + }, + ) + registry = FakeRegistry() + flow = OrderFlowHarness(state=state, registry=registry) + + response = await flow._try_collect_and_create_order( + message="liste os carros com esse valor em estoque", + user_id=10, + extracted_fields={}, + intents={}, + turn_decision={"intent": "inventory_search", "domain": "sales", "action": "call_tool"}, + ) + + self.assertEqual(registry.calls[0][0], "consultar_estoque") + self.assertIn("Encontrei 2 veiculo(s):", response) + self.assertIn("Honda Civic 2021", response) + self.assertEqual(len(flow._get_last_stock_results(user_id=10)), 2) + + async def test_order_flow_accepts_turn_decision_without_legacy_intents(self): + state = FakeState( + contexts={ + 10: { + "generic_memory": {"cpf": "12345678909"}, + "last_stock_results": [ + {"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 51524.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="esse", + user_id=10, + extracted_fields={"vehicle_id": 1}, + intents={}, + turn_decision={"intent": "order_create", "domain": "sales", "action": "collect_order_create"}, + ) + + 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"], 1) + self.assertEqual(arguments["cpf"], "12345678909") + self.assertIn("Pedido criado com sucesso.", response) + + async def test_order_flow_accepts_model_intent_without_keyword_trigger(self): + state = FakeState( + contexts={ + 10: { + "generic_memory": {"cpf": "12345678909"}, + "last_stock_results": [ + {"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 51524.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="esse", + user_id=10, + extracted_fields={"vehicle_id": 1}, + intents={"order_create": True}, + ) + + 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"], 1) + self.assertEqual(arguments["cpf"], "12345678909") + self.assertIn("Pedido criado com sucesso.", response) + async def test_order_flow_requests_vehicle_selection_from_last_stock_results(self): state = FakeState( contexts={