diff --git a/app/services/flows/order_flow.py b/app/services/flows/order_flow.py index f223931..2d2f57e 100644 --- a/app/services/flows/order_flow.py +++ b/app/services/flows/order_flow.py @@ -154,6 +154,13 @@ class OrderFlowMixin: selected_vehicle = context.get("selected_vehicle") return dict(selected_vehicle) if isinstance(selected_vehicle, dict) else None + def _get_pending_single_vehicle_confirmation(self, user_id: int | None) -> dict | None: + context = self._get_user_context(user_id) + if not context: + return None + pending_vehicle = context.get("pending_single_vehicle_confirmation") + return dict(pending_vehicle) if isinstance(pending_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: @@ -178,6 +185,7 @@ class OrderFlowMixin: context["last_stock_results"] = sanitized if sanitized: context["selected_vehicle"] = None + context["pending_single_vehicle_confirmation"] = None self._save_user_context(user_id=user_id, context=context) def _store_selected_vehicle(self, user_id: int | None, vehicle: dict | None) -> None: @@ -187,6 +195,25 @@ class OrderFlowMixin: if not context: return context["selected_vehicle"] = dict(vehicle) if isinstance(vehicle, dict) else None + context["pending_single_vehicle_confirmation"] = None + self._save_user_context(user_id=user_id, context=context) + + def _store_pending_single_vehicle_confirmation(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["pending_single_vehicle_confirmation"] = dict(vehicle) if isinstance(vehicle, dict) else None + self._save_user_context(user_id=user_id, context=context) + + def _clear_pending_single_vehicle_confirmation(self, user_id: int | None) -> None: + if user_id is None: + return + context = self._get_user_context(user_id) + if not isinstance(context, dict): + return + context["pending_single_vehicle_confirmation"] = None self._save_user_context(user_id=user_id, context=context) def _vehicle_to_payload(self, vehicle: dict) -> dict: @@ -199,6 +226,8 @@ class OrderFlowMixin: 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 + if self._get_pending_single_vehicle_confirmation(user_id=user_id): + return selected_vehicle = self._get_selected_vehicle(user_id=user_id) if selected_vehicle: payload.update(self._vehicle_to_payload(selected_vehicle)) @@ -282,6 +311,7 @@ class OrderFlowMixin: return context["last_stock_results"] = [] context["selected_vehicle"] = None + context["pending_single_vehicle_confirmation"] = None self._save_user_context(user_id=user_id, context=context) def _match_vehicle_from_message_index(self, message: str, stock_results: list[dict]) -> dict | None: @@ -365,6 +395,25 @@ class OrderFlowMixin: lines.append("Pode responder com o numero da lista ou com o modelo do veiculo.") return "\n".join(lines) + def _render_single_vehicle_confirmation_prompt(self, vehicle: dict) -> str: + return ( + "Encontrei 1 opcao para o seu pedido:\n" + f"- 1. {vehicle.get('modelo', 'N/A')} ({vehicle.get('categoria', 'N/A')}) - " + f"R$ {float(vehicle.get('preco', 0)):.2f}\n" + "Posso seguir com essa opcao? Responda com 1, sim ou com o modelo do veiculo." + ) + + def _message_confirms_single_vehicle(self, message: str, vehicle: dict) -> bool: + normalized_message = self._normalize_text(message).strip() + if self._is_affirmative_message(message): + return True + if normalized_message == "1": + return True + normalized_model = self._normalize_text(str(vehicle.get("modelo") or "")).strip() + if normalized_model and normalized_model in normalized_message: + return True + return False + async def _try_list_stock_for_order_selection( self, message: str, @@ -391,7 +440,11 @@ class OrderFlowMixin: 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 []) + stock_results = tool_result if isinstance(tool_result, list) else [] + self._remember_stock_results(user_id=user_id, stock_results=stock_results) + if len(stock_results) == 1: + self._store_pending_single_vehicle_confirmation(user_id=user_id, vehicle=stock_results[0]) + return self._render_single_vehicle_confirmation_prompt(stock_results[0]) return self._fallback_format_tool_result("consultar_estoque", tool_result) def _render_missing_cancel_order_fields_prompt(self, missing_fields: list[str]) -> str: @@ -497,6 +550,22 @@ class OrderFlowMixin: draft["payload"].pop("modelo_veiculo", None) draft["payload"].pop("valor_veiculo", None) + pending_single_vehicle = self._get_pending_single_vehicle_confirmation(user_id=user_id) + if pending_single_vehicle and not draft["payload"].get("vehicle_id"): + if self._message_confirms_single_vehicle(message=message, vehicle=pending_single_vehicle): + self._store_selected_vehicle(user_id=user_id, vehicle=pending_single_vehicle) + draft["payload"].update(self._vehicle_to_payload(pending_single_vehicle)) + pending_single_vehicle = None + elif self._is_negative_message(message): + self._clear_pending_single_vehicle_confirmation(user_id=user_id) + draft["expires_at"] = datetime.utcnow() + timedelta(minutes=PENDING_ORDER_DRAFT_TTL_MINUTES) + self.state.set_entry("pending_order_drafts", user_id, draft) + return "Sem problema. Me diga outro modelo ou ajuste o valor para eu buscar novas opcoes." + elif not self._has_explicit_order_request(message): + draft["expires_at"] = datetime.utcnow() + timedelta(minutes=PENDING_ORDER_DRAFT_TTL_MINUTES) + self.state.set_entry("pending_order_drafts", user_id, draft) + return self._render_single_vehicle_confirmation_prompt(pending_single_vehicle) + resolved_vehicle = self._try_resolve_order_vehicle( message=message, user_id=user_id, diff --git a/app/services/flows/review_flow.py b/app/services/flows/review_flow.py index b3a37ce..8a582c1 100644 --- a/app/services/flows/review_flow.py +++ b/app/services/flows/review_flow.py @@ -121,6 +121,16 @@ class ReviewFlowMixin: payload["data_hora"] = f"{payload['data_hora_base']} {time_text}" payload.pop("data_hora_base", None) + def _store_review_base_date_from_message(self, message: str, payload: dict) -> None: + if not isinstance(payload, dict): + return + if payload.get("data_hora") or payload.get("data_hora_base"): + return + date_only = self._extract_review_date_only_text(message) + if not date_only: + return + payload["data_hora_base"] = date_only + def _infer_review_management_action( self, message: str, @@ -521,6 +531,8 @@ class ReviewFlowMixin: self._merge_review_base_date_with_time(message=message, payload=draft["payload"]) self._supplement_review_fields_from_message(message=message, payload=draft["payload"]) self._merge_review_base_date_with_time(message=message, payload=draft["payload"]) + self._store_review_base_date_from_message(message=message, payload=draft["payload"]) + self._merge_review_base_date_with_time(message=message, payload=draft["payload"]) self._try_prefill_review_fields_from_memory(user_id=user_id, payload=draft["payload"]) if ( "revisao_previa_concessionaria" not in draft["payload"] @@ -537,6 +549,11 @@ class ReviewFlowMixin: missing = [field for field in REVIEW_REQUIRED_FIELDS if field not in draft["payload"]] if missing: self._log_review_flow_source(source=review_flow_source or "draft", payload=draft["payload"], missing_fields=missing) + if missing == ["data_hora"] and draft["payload"].get("data_hora_base"): + return ( + f"Perfeito. Tenho a data {draft['payload']['data_hora_base']}. " + "Agora me informe o horario desejado para a revisao." + ) return self._render_missing_review_fields_prompt(missing) try: diff --git a/tests/test_conversation_adjustments.py b/tests/test_conversation_adjustments.py index 9b8b76e..d58b0b0 100644 --- a/tests/test_conversation_adjustments.py +++ b/tests/test_conversation_adjustments.py @@ -182,6 +182,14 @@ class OrderFlowHarness(OrderFlowMixin): def _normalize_text(self, text: str) -> str: return self.normalizer.normalize_text(text) + 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", "continuar"} + + def _is_negative_message(self, text: str) -> bool: + normalized = self.normalizer.normalize_text(text).strip().rstrip(".!?,;:") + return normalized in {"nao", "não", "negativo", "cancelar", "outro", "outra opcao", "outra opção"} + def _normalize_positive_number(self, value): return self.normalizer.normalize_positive_number(value) @@ -800,6 +808,86 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase): self.assertIn("Honda Civic 2021", response) self.assertEqual(registry.calls, []) + async def test_order_flow_single_stock_result_requires_explicit_confirmation(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": 70000}, + "shared_memory": {"orcamento_max": 70000}, + "last_stock_results": [], + "selected_vehicle": None, + } + }, + ) + registry = FakeRegistry() + + async def single_result_execute(tool_name: str, arguments: dict, user_id: int | None = None): + registry.calls.append((tool_name, arguments, user_id)) + if tool_name == "consultar_estoque": + return [{"id": 7, "modelo": "Fiat Argo 2020", "categoria": "suv", "preco": 61857.0}] + if tool_name == "realizar_pedido": + return { + "numero_pedido": "PED-TESTE-123", + "status": "Ativo", + "modelo_veiculo": "Fiat Argo 2020", + "valor_veiculo": 61857.0, + } + raise AssertionError(f"Tool inesperada no teste: {tool_name}") + + registry.execute = single_result_execute + 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="quero comprar um carro de ate 70 mil", + user_id=10, + extracted_fields={}, + intents={"order_create": True}, + ) + + self.assertIn("Encontrei 1 opcao", response) + self.assertIn("Fiat Argo 2020", response) + self.assertEqual(len(registry.calls), 1) + self.assertEqual(registry.calls[0][0], "consultar_estoque") + self.assertEqual(state.get_user_context(10)["selected_vehicle"], None) + self.assertIsNotNone(state.get_user_context(10)["pending_single_vehicle_confirmation"]) + + follow_up_response = await flow._try_collect_and_create_order( + message="12345678909", + user_id=10, + extracted_fields={}, + intents={}, + ) + + self.assertIn("Posso seguir com essa opcao", follow_up_response) + self.assertEqual(len(registry.calls), 1) + + confirmation_response = await flow._try_collect_and_create_order( + message="sim", + user_id=10, + extracted_fields={}, + intents={}, + ) + + self.assertEqual(len(registry.calls), 2) + self.assertEqual(registry.calls[1][0], "realizar_pedido") + self.assertEqual(registry.calls[1][1]["vehicle_id"], 7) + self.assertIn("Pedido criado com sucesso.", confirmation_response) + async def test_order_flow_creates_order_with_selected_vehicle_from_list_index(self): state = FakeState( entries={ @@ -1357,6 +1445,62 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(registry.calls[0][1]["data_hora"], f"{today_text} 14:00") self.assertIn("REV-TESTE-123", final_response) + async def test_review_flow_reuse_confirmation_accepts_relative_date_then_time_in_follow_up(self): + state = FakeState( + entries={ + "pending_review_reuse_confirmations": { + 21: { + "payload": { + "placa": "ABC1263", + "modelo": "Onix", + "ano": 2024, + "km": 50000, + "revisao_previa_concessionaria": False, + }, + "expires_at": datetime.utcnow() + timedelta(minutes=30), + } + } + } + ) + registry = FakeRegistry() + flow = ReviewFlowHarness(state=state, registry=registry) + today_text = datetime.now().strftime("%d/%m/%Y") + + first_response = await flow._try_collect_and_schedule_review( + message="sim", + user_id=21, + extracted_fields={}, + intents={}, + turn_decision={"intent": "review_schedule", "domain": "review", "action": "collect_review_schedule"}, + ) + + self.assertIn("Me informe apenas a data e hora desejada", first_response) + + second_response = await flow._try_collect_and_schedule_review( + message="quero marcar para hoje", + user_id=21, + extracted_fields={}, + intents={}, + turn_decision={"intent": "review_schedule", "domain": "review", "action": "collect_review_schedule"}, + ) + + draft = state.get_entry("pending_review_drafts", 21) + self.assertIsNotNone(draft) + self.assertEqual(draft["payload"].get("data_hora_base"), today_text) + self.assertIn("Agora me informe o horario desejado", second_response) + + final_response = await flow._try_collect_and_schedule_review( + message="as 14 horas", + user_id=21, + extracted_fields={}, + intents={}, + turn_decision={"intent": "review_schedule", "domain": "review", "action": "collect_review_schedule"}, + ) + + self.assertEqual(registry.calls[0][0], "agendar_revisao") + self.assertEqual(registry.calls[0][1]["data_hora"], f"{today_text} 14:00") + self.assertIn("REV-TESTE-123", final_response) + async def test_review_flow_clears_stale_pending_confirmation_when_user_starts_new_schedule(self): state = FakeState( entries={