diff --git a/app/services/flows/order_flow.py b/app/services/flows/order_flow.py index 3049cb8..323ec3d 100644 --- a/app/services/flows/order_flow.py +++ b/app/services/flows/order_flow.py @@ -161,6 +161,66 @@ class OrderFlowMixin: arguments["ordenar_preco"] = "asc" return arguments + def _should_refresh_stock_context(self, user_id: int | None, payload: dict | None = None) -> bool: + if user_id is None: + return False + context = self._get_user_context(user_id) + if not isinstance(context, dict): + return False + + generic_memory = context.get("generic_memory", {}) if isinstance(context.get("generic_memory"), dict) else {} + selected_vehicle = context.get("selected_vehicle") + last_stock_results = context.get("last_stock_results") or [] + source = payload if isinstance(payload, dict) else {} + + budget = generic_memory.get("orcamento_max") + if isinstance(budget, (int, float)) and float(budget) > 0: + if isinstance(selected_vehicle, dict): + try: + if float(selected_vehicle.get("preco") or 0) > float(budget): + return True + except (TypeError, ValueError): + return True + for item in last_stock_results: + if not isinstance(item, dict): + continue + try: + if float(item.get("preco") or 0) > float(budget): + return True + except (TypeError, ValueError): + return True + + perfil = generic_memory.get("perfil_veiculo") + expected_category = str(perfil[0]).strip().lower() if isinstance(perfil, list) and perfil else None + if expected_category: + if isinstance(selected_vehicle, dict): + if str(selected_vehicle.get("categoria") or "").strip().lower() != expected_category: + return True + for item in last_stock_results: + if not isinstance(item, dict): + continue + if str(item.get("categoria") or "").strip().lower() != expected_category: + return True + + vehicle_budget = source.get("valor_veiculo") + if isinstance(vehicle_budget, (int, float)) and isinstance(selected_vehicle, dict): + try: + if float(selected_vehicle.get("preco") or 0) > float(vehicle_budget): + return True + except (TypeError, ValueError): + return True + + return False + + def _reset_order_stock_context(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["last_stock_results"] = [] + context["selected_vehicle"] = None + 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: @@ -236,7 +296,7 @@ class OrderFlowMixin: 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"- {idx}. {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.") @@ -248,8 +308,11 @@ class OrderFlowMixin: user_id: int | None, payload: dict, turn_decision: dict | None = None, + force: bool = False, ) -> str | None: - if user_id is None or not self._has_stock_listing_request(message, turn_decision=turn_decision): + if user_id is None: + return None + if not force and 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) @@ -257,7 +320,7 @@ class OrderFlowMixin: return None try: - tool_result = await self.registry.execute( + tool_result = await self.tool_executor.execute( "consultar_estoque", arguments, user_id=user_id, @@ -330,6 +393,12 @@ class OrderFlowMixin: 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"]) + if self._should_refresh_stock_context(user_id=user_id, payload=draft["payload"]): + self._reset_order_stock_context(user_id=user_id) + draft["payload"].pop("vehicle_id", None) + draft["payload"].pop("modelo_veiculo", None) + draft["payload"].pop("valor_veiculo", None) + resolved_vehicle = self._try_resolve_order_vehicle( message=message, user_id=user_id, @@ -368,6 +437,7 @@ class OrderFlowMixin: user_id=user_id, payload=draft["payload"], turn_decision=turn_decision, + force=has_intent or explicit_order_request, ) if stock_response: return stock_response @@ -377,7 +447,7 @@ class OrderFlowMixin: return self._render_missing_order_fields_prompt(missing) try: - tool_result = await self.registry.execute( + tool_result = await self.tool_executor.execute( "realizar_pedido", { "cpf": draft["payload"]["cpf"], @@ -386,6 +456,11 @@ class OrderFlowMixin: user_id=user_id, ) except HTTPException as exc: + error = self.tool_executor.coerce_http_error(exc) + if error.get("retryable") and error.get("field"): + draft["payload"].pop(str(error["field"]), None) + draft["expires_at"] = datetime.utcnow() + timedelta(minutes=PENDING_ORDER_DRAFT_TTL_MINUTES) + self.state.set_entry("pending_order_drafts", user_id, draft) return self._http_exception_detail(exc) self.state.pop_entry("pending_order_drafts", user_id) @@ -471,12 +546,17 @@ class OrderFlowMixin: return self._render_missing_cancel_order_fields_prompt(missing) try: - tool_result = await self.registry.execute( + tool_result = await self.tool_executor.execute( "cancelar_pedido", draft["payload"], user_id=user_id, ) except HTTPException as exc: + error = self.tool_executor.coerce_http_error(exc) + if error.get("retryable") and error.get("field"): + draft["payload"].pop(str(error["field"]), None) + draft["expires_at"] = datetime.utcnow() + timedelta(minutes=PENDING_CANCEL_ORDER_DRAFT_TTL_MINUTES) + self.state.set_entry("pending_cancel_order_drafts", user_id, draft) return self._http_exception_detail(exc) self.state.pop_entry("pending_cancel_order_drafts", user_id) diff --git a/app/services/orchestration/response_formatter.py b/app/services/orchestration/response_formatter.py index 09b2219..eda4147 100644 --- a/app/services/orchestration/response_formatter.py +++ b/app/services/orchestration/response_formatter.py @@ -31,11 +31,11 @@ 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")) - codigo = item.get("id", "N/A") - linhas.append(f"{idx}. [{codigo}] {modelo} ({categoria}) - {preco}") + linhas.append(f"{idx}. {modelo} ({categoria}) - {preco}") restantes = len(tool_result) - 10 if restantes > 0: linhas.append(f"... e mais {restantes} veiculo(s).") + linhas.append("Para escolher, responda com o numero da opcao desejada. Exemplo: 1.") return "\n".join(linhas) if tool_name == "cancelar_pedido" and isinstance(tool_result, dict): diff --git a/tests/test_conversation_adjustments.py b/tests/test_conversation_adjustments.py index ee9c90d..23f1123 100644 --- a/tests/test_conversation_adjustments.py +++ b/tests/test_conversation_adjustments.py @@ -5,7 +5,10 @@ from unittest.mock import patch os.environ.setdefault("DEBUG", "false") +from fastapi import HTTPException + from app.services.flows.order_flow import OrderFlowMixin +from app.services.flows.review_flow import ReviewFlowMixin 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 @@ -53,9 +56,12 @@ class FakeService: class FakeRegistry: def __init__(self): self.calls = [] + self.raise_http_exception = None async def execute(self, tool_name: str, arguments: dict, user_id: int | None = None): self.calls.append((tool_name, arguments, user_id)) + if self.raise_http_exception is not None: + raise self.raise_http_exception if tool_name == "consultar_estoque": return [ {"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 48500.0}, @@ -65,6 +71,8 @@ class FakeRegistry: vehicle_map = { 1: ("Honda Civic 2021", 51524.0), 2: ("Toyota Corolla 2020", 58476.0), + 3: ("Chevrolet Onix 2022", 51809.0), + 7: ("Fiat Argo 2020", 61857.0), } modelo_veiculo, valor_veiculo = vehicle_map[arguments["vehicle_id"]] return { @@ -79,11 +87,21 @@ class FakeRegistry: "motivo": arguments["motivo"], } + def coerce_http_error(self, exc): + detail = exc.detail if isinstance(exc.detail, dict) else {"message": str(exc.detail)} + return { + "code": detail.get("code", "tool_error"), + "message": detail.get("message", str(exc.detail)), + "retryable": bool(detail.get("retryable", False)), + "field": detail.get("field"), + } + class OrderFlowHarness(OrderFlowMixin): def __init__(self, state, registry): self.state = state self.registry = registry + self.tool_executor = registry self.normalizer = EntityNormalizer() def _get_user_context(self, user_id: int | None): @@ -108,7 +126,8 @@ class OrderFlowHarness(OrderFlowMixin): 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}") + lines.append(f"{idx}. {item['modelo']} ({item['categoria']}) - R$ {item['preco']:.2f}") + lines.append("Para escolher, responda com o numero da opcao desejada. Exemplo: 1.") return "\n".join(lines) if tool_name == "realizar_pedido": return ( @@ -134,6 +153,51 @@ class OrderFlowHarness(OrderFlowMixin): return None +class ReviewFlowHarness(ReviewFlowMixin): + def __init__(self, state, registry): + self.state = state + self.registry = registry + self.tool_executor = registry + self.normalizer = EntityNormalizer() + self.captured_suggestions = [] + + def _normalize_intents(self, data) -> dict: + return self.normalizer.normalize_intents(data) + + def _normalize_review_fields(self, data) -> dict: + return self.normalizer.normalize_review_fields(data) + + def _normalize_review_management_fields(self, data) -> dict: + return self.normalizer.normalize_review_management_fields(data) + + def _normalize_text(self, text: str) -> str: + return self.normalizer.normalize_text(text) + + def _http_exception_detail(self, exc) -> str: + detail = exc.detail if isinstance(exc.detail, dict) else {} + return str(detail.get("message") or exc) + + def _fallback_format_tool_result(self, tool_name: str, tool_result) -> str: + return f"{tool_name}:{tool_result}" + + def _extract_review_protocol_from_text(self, text: str) -> str | None: + return self.normalizer.extract_review_protocol_from_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", "tenho", "tenho sim"} + + def _is_negative_message(self, text: str) -> bool: + normalized = self.normalizer.normalize_text(text).strip().rstrip(".!?,;:") + return normalized in {"nao", "nao quero", "prefiro outro", "outro horario"} or normalized.startswith("nao") + + def _capture_review_confirmation_suggestion(self, **kwargs) -> None: + self.captured_suggestions.append(kwargs) + + def _try_prefill_review_fields_from_memory(self, user_id: int | None, payload: dict) -> None: + return None + + class ConversationAdjustmentsTests(unittest.TestCase): def test_defer_flow_cancel_when_order_cancel_draft_waits_for_reason(self): state = FakeState( @@ -276,15 +340,23 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase): 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"}, - ) + 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 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()) + self.assertIn("Encontrei 2 veiculo(s):", response) + self.assertIn("Honda Civic 2021", response) async def test_order_flow_lists_stock_from_budget_when_vehicle_is_missing(self): state = FakeState( @@ -307,13 +379,20 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase): 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"}, - ) + 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="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) @@ -468,6 +547,218 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(arguments["cpf"], "12345678909") self.assertIn("Veiculo: Toyota Corolla 2020", response) + async def test_order_flow_selection_uses_list_position_not_vehicle_id(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": 3, "modelo": "Chevrolet Onix 2022", "categoria": "suv", "preco": 51809.0}, + {"id": 7, "modelo": "Fiat Argo 2020", "categoria": "suv", "preco": 61857.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="3", + user_id=10, + extracted_fields={}, + intents={}, + ) + + self.assertEqual(registry.calls, []) + self.assertIn("escolha primeiro qual veiculo", response.lower()) + self.assertIn("1. Chevrolet Onix 2022", response) + self.assertIn("2. Fiat Argo 2020", response) + + async def test_order_flow_keeps_draft_and_clears_retryable_field_on_tool_error(self): + state = FakeState( + entries={ + "pending_order_drafts": { + 10: { + "payload": {"cpf": "12345678909", "vehicle_id": 99}, + "expires_at": datetime.utcnow() + timedelta(minutes=30), + } + } + }, + contexts={ + 10: { + "generic_memory": {"cpf": "12345678909"}, + "last_stock_results": [], + "selected_vehicle": None, + } + }, + ) + registry = FakeRegistry() + registry.raise_http_exception = HTTPException( + status_code=409, + detail={ + "code": "vehicle_already_reserved", + "message": "Este veiculo ja esta reservado e nao aparece mais no estoque disponivel.", + "retryable": True, + "field": "vehicle_id", + }, + ) + 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 esse carro", + user_id=10, + extracted_fields={}, + intents={}, + ) + + draft = state.get_entry("pending_order_drafts", 10) + self.assertIn("ja esta reservado", response) + self.assertIsNotNone(draft) + self.assertEqual(draft["payload"].get("cpf"), "12345678909") + self.assertNotIn("vehicle_id", draft["payload"]) + + async def test_order_flow_refreshes_stale_stock_results_when_budget_changes(self): + state = FakeState( + contexts={ + 10: { + "generic_memory": {"cpf": "12345678909", "orcamento_max": 45000}, + "last_stock_results": [ + {"id": 3, "modelo": "Chevrolet Onix 2022", "categoria": "suv", "preco": 51809.0}, + {"id": 7, "modelo": "Fiat Argo 2020", "categoria": "suv", "preco": 61857.0}, + ], + "selected_vehicle": {"id": 3, "modelo": "Chevrolet Onix 2022", "categoria": "suv", "preco": 51809.0}, + } + } + ) + 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="Quero comprar um carro de 45 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.assertEqual(registry.calls[0][0], "consultar_estoque") + self.assertNotIn("Chevrolet Onix 2022", response) + self.assertEqual(state.get_user_context(10)["selected_vehicle"], None) + self.assertEqual(len(state.get_user_context(10)["last_stock_results"]), 2) + + async def test_order_flow_refreshes_stale_stock_results_when_profile_changes(self): + state = FakeState( + contexts={ + 10: { + "generic_memory": {"cpf": "12345678909", "orcamento_max": 50000, "perfil_veiculo": ["hatch"]}, + "last_stock_results": [ + {"id": 3, "modelo": "Chevrolet Onix 2022", "categoria": "suv", "preco": 48000.0}, + ], + "selected_vehicle": {"id": 3, "modelo": "Chevrolet Onix 2022", "categoria": "suv", "preco": 48000.0}, + } + } + ) + 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="Quero comprar um hatch 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.assertEqual(registry.calls[0][0], "consultar_estoque") + self.assertEqual(state.get_user_context(10)["selected_vehicle"], None) + self.assertTrue( + all(item.get("categoria") != "suv" for item in state.get_user_context(10)["last_stock_results"]) + ) + + +class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase): + async def test_review_flow_keeps_draft_and_clears_data_hora_on_retryable_error(self): + state = FakeState( + entries={ + "pending_review_drafts": { + 21: { + "payload": { + "placa": "ABC1234", + "data_hora": "2026-03-10T09:00:00-03:00", + "modelo": "HB20", + "ano": 2022, + "km": 15000, + "revisao_previa_concessionaria": True, + }, + "expires_at": datetime.utcnow() + timedelta(minutes=30), + } + } + } + ) + registry = FakeRegistry() + registry.raise_http_exception = HTTPException( + status_code=409, + detail={ + "code": "review_schedule_conflict", + "message": "O horario solicitado esta ocupado.", + "retryable": True, + "field": "data_hora", + "suggested_iso": "2026-03-10T09:30:00-03:00", + }, + ) + flow = ReviewFlowHarness(state=state, registry=registry) + + response = await flow._try_collect_and_schedule_review( + message="agendar revisao", + user_id=21, + extracted_fields={}, + intents={}, + turn_decision={"intent": "review_schedule", "domain": "review", "action": "call_tool"}, + ) + + draft = state.get_entry("pending_review_drafts", 21) + self.assertIn("ocupado", response) + self.assertIsNotNone(draft) + self.assertEqual(draft["payload"].get("placa"), "ABC1234") + self.assertNotIn("data_hora", draft["payload"]) + if __name__ == "__main__": unittest.main()