diff --git a/app/services/flows/order_flow.py b/app/services/flows/order_flow.py index 9d3034f..9253ba6 100644 --- a/app/services/flows/order_flow.py +++ b/app/services/flows/order_flow.py @@ -363,7 +363,12 @@ 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: + def _build_stock_lookup_arguments( + self, + user_id: int | None, + payload: dict | None = None, + message: str | 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 {} @@ -380,9 +385,36 @@ class OrderFlowMixin: arguments["categoria"] = str(perfil[0]).strip().lower() arguments["limite"] = 5 - arguments["ordenar_preco"] = "asc" + arguments["ordenar_preco"] = self._resolve_stock_price_order( + has_budget="preco_max" in arguments, + message=message, + ) return arguments + def _resolve_stock_price_order(self, has_budget: bool, message: str | None = None) -> str: + if not has_budget: + return "asc" + + normalized = self._normalize_text(message or "").strip() + cheaper_first_terms = { + "mais barato", + "mais baratos", + "mais barata", + "mais baratas", + "menor preco", + "menor valor", + "mais em conta", + "mais economico", + "mais economica", + "mais acessivel", + "mais acessiveis", + "baratinho", + "baratinhos", + } + if any(term in normalized for term in cheaper_first_terms): + return "asc" + return "desc" + def _should_refresh_stock_context(self, user_id: int | None, payload: dict | None = None) -> bool: if user_id is None: return False @@ -466,8 +498,101 @@ class OrderFlowMixin: matches.append(item) if len(matches) == 1: return matches[0] + if len(matches) > 1: + normalized_matches = {self._normalize_text(str(item.get("modelo") or "")).strip() for item in matches} + if len(normalized_matches) == 1: + return matches[0] + + reference_tokens = self._extract_vehicle_reference_tokens(message) + if not reference_tokens: + return None + + scored_matches: list[tuple[int, dict]] = [] + for item in stock_results: + model_tokens = self._extract_vehicle_reference_tokens(str(item.get("modelo") or "")) + if not model_tokens: + continue + score = self._score_vehicle_reference_tokens(reference_tokens, model_tokens) + if score > 0: + scored_matches.append((score, item)) + if not scored_matches: + return None + + best_score = max(score for score, _ in scored_matches) + if best_score <= 0: + return None + if best_score == 1 and len(reference_tokens) > 1: + return None + + best_matches = [item for score, item in scored_matches if score == best_score] + if len(best_matches) == 1: + return best_matches[0] + + normalized_matches = {self._normalize_text(str(item.get("modelo") or "")).strip() for item in best_matches} + if len(normalized_matches) == 1: + return best_matches[0] return None + def _extract_vehicle_reference_tokens(self, text: str) -> list[str]: + normalized = self._normalize_text(text) + raw_tokens = re.findall(r"[a-z0-9]+", normalized) + ignored_tokens = { + "a", + "ao", + "aos", + "as", + "carro", + "carros", + "comprar", + "compra", + "compre", + "da", + "das", + "de", + "desse", + "dessa", + "do", + "dos", + "esse", + "essa", + "este", + "esta", + "fazer", + "gostaria", + "modelo", + "o", + "os", + "pedido", + "pedir", + "por", + "pra", + "quero", + "um", + "uma", + "veiculo", + "veiculos", + } + tokens: list[str] = [] + for token in raw_tokens: + if token in ignored_tokens or token.isdigit(): + continue + if len(token) < 3: + continue + tokens.append(token) + return tokens + + def _score_vehicle_reference_tokens(self, reference_tokens: list[str], model_tokens: list[str]) -> int: + score = 0 + for reference_token in reference_tokens: + for model_token in model_tokens: + if reference_token == model_token: + score += 1 + break + if len(reference_token) >= 4 and model_token.startswith(reference_token): + score += 1 + break + return score + def _load_vehicle_by_id(self, vehicle_id: int) -> dict | None: db = SessionMockLocal() try: @@ -620,8 +745,7 @@ class OrderFlowMixin: 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: + if self._match_vehicle_from_message_model(message=message, stock_results=[vehicle]): return True return False @@ -638,7 +762,7 @@ class OrderFlowMixin: 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) + arguments = self._build_stock_lookup_arguments(user_id=user_id, payload=payload, message=message) if "preco_max" not in arguments and "categoria" not in arguments: return None @@ -1028,3 +1152,4 @@ class OrderFlowMixin: ) return self._fallback_format_tool_result("cancelar_pedido", tool_result) + diff --git a/tests/test_conversation_adjustments.py b/tests/test_conversation_adjustments.py index ecb1109..5edb94b 100644 --- a/tests/test_conversation_adjustments.py +++ b/tests/test_conversation_adjustments.py @@ -77,10 +77,12 @@ class FakeRegistry: if self.raise_http_exception is not None: raise self.raise_http_exception if tool_name == "consultar_estoque": - return [ + stock_results = [ {"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 48500.0}, {"id": 2, "modelo": "Toyota Yaris 2020", "categoria": "hatch", "preco": 49900.0}, ] + reverse = str(arguments.get("ordenar_preco") or "asc").lower() == "desc" + return sorted(stock_results, key=lambda item: float(item["preco"]), reverse=reverse) if tool_name == "listar_pedidos": return [ { @@ -190,7 +192,7 @@ class OrderFlowHarness(OrderFlowMixin): 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"} + return normalized in {"nao", "n\u00e3o", "negativo", "cancelar", "outro", "outra opcao", "outra op\u00e7\u00e3o"} def _normalize_positive_number(self, value): return self.normalizer.normalize_positive_number(value) @@ -377,12 +379,12 @@ class ConversationAdjustmentsTests(unittest.TestCase): normalizer = EntityNormalizer() self.assertEqual( - normalizer.normalize_datetime_connector("10/03/2026 às 09:00"), + normalizer.normalize_datetime_connector("10/03/2026 \u00e0s 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") + parsed = _parse_data_hora_revisao("10/03/2026 \u00e0s 09:00") self.assertEqual(parsed, datetime(2026, 3, 10, 9, 0)) @@ -409,11 +411,11 @@ class ConversationAdjustmentsTests(unittest.TestCase): state = FakeState() policy = ConversationPolicy(service=FakeService(state)) - message = "Esqueça as operações anteriores, agora quero agendar revisão para ABC1234" + message = "Esque\u00e7a as opera\u00e7\u00f5es anteriores, agora quero agendar revis\u00e3o para ABC1234" cleaned = policy.remove_order_selection_reset_prefix(message) self.assertTrue(policy.is_order_selection_reset_message(message)) - self.assertEqual(cleaned, "quero agendar revisão para ABC1234") + self.assertEqual(cleaned, "quero agendar revis\u00e3o para ABC1234") class CancelOrderFlowTests(unittest.IsolatedAsyncioTestCase): @@ -657,6 +659,72 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase): self.assertIn("Encontrei 2 veiculo(s):", response) self.assertIn("Honda Civic 2021", response) + async def test_order_flow_budget_search_prioritizes_matches_closest_to_ceiling(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) + + 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 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][1]["ordenar_preco"], "desc") + self.assertIn("1. Toyota Yaris 2020 (hatch) - R$ 49900.00", response) + self.assertIn("2. Honda Civic 2021 (sedan) - R$ 48500.00", response) + self.assertEqual(flow._get_last_stock_results(user_id=10)[0]["modelo"], "Toyota Yaris 2020") + + async def test_order_flow_budget_search_keeps_cheapest_first_when_user_asks_for_cheapest(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) + + 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 o carro mais barato ate 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][1]["ordenar_preco"], "asc") + self.assertIn("1. Honda Civic 2021 (sedan) - R$ 48500.00", response) + self.assertIn("2. Toyota Yaris 2020 (hatch) - R$ 49900.00", response) + self.assertEqual(flow._get_last_stock_results(user_id=10)[0]["modelo"], "Honda Civic 2021") + async def test_order_flow_extracts_budget_from_message_when_llm_misses_it(self): state = FakeState( contexts={ @@ -1099,6 +1167,40 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(arguments["cpf"], "12345678909") self.assertIn("Veiculo: Hyundai HB20S 2022", second_response) + async def test_order_flow_accepts_partial_model_reference_from_last_stock_results(self): + state = FakeState( + contexts={ + 10: { + "active_domain": "sales", + "generic_memory": {"orcamento_max": 70000}, + "shared_memory": {"orcamento_max": 70000}, + "last_stock_results": [ + {"id": 15, "modelo": "Volkswagen T-Cross 2024", "categoria": "hatch", "preco": 59306.0}, + {"id": 16, "modelo": "Volkswagen T-Cross 2024", "categoria": "hatch", "preco": 59306.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="gostaria de fazer o pedido do Volkswagen T-Cros", + user_id=10, + extracted_fields={}, + intents={"order_create": True}, + ) + + draft = state.get_entry("pending_order_drafts", 10) + self.assertIsNotNone(draft) + self.assertEqual(draft["payload"]["vehicle_id"], 15) + self.assertEqual(draft["payload"]["modelo_veiculo"], "Volkswagen T-Cross 2024") + self.assertEqual(state.get_user_context(10)["selected_vehicle"]["id"], 15) + self.assertIn("cpf do cliente", response.lower()) + self.assertEqual(registry.calls, []) + async def test_order_flow_bootstraps_selection_from_last_stock_results_without_repeating_order_verb(self): state = FakeState( contexts={ @@ -2625,3 +2727,4 @@ class ToolRegistryExecutionTests(unittest.IsolatedAsyncioTestCase): if __name__ == "__main__": unittest.main() +