diff --git a/app/services/flows/order_flow.py b/app/services/flows/order_flow.py index 323ec3d..508d890 100644 --- a/app/services/flows/order_flow.py +++ b/app/services/flows/order_flow.py @@ -5,7 +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.technical_normalizer import extract_budget_from_text, extract_cpf_from_text, is_valid_cpf from app.services.orchestration.orchestrator_config import ( CANCEL_ORDER_REQUIRED_FIELDS, ORDER_REQUIRED_FIELDS, @@ -56,6 +56,30 @@ class OrderFlowMixin: def _is_valid_cpf(self, cpf: str) -> bool: return is_valid_cpf(cpf) + def _try_capture_order_cpf_from_message(self, message: str, payload: dict) -> None: + if payload.get("cpf"): + return + cpf = extract_cpf_from_text(message) + if cpf and self._is_valid_cpf(cpf): + payload["cpf"] = cpf + + def _try_capture_order_budget_from_message(self, user_id: int | None, message: str, payload: dict) -> None: + if not self._has_explicit_order_request(message) and self.state.get_entry("pending_order_drafts", user_id, expire=True) is None: + return + context = self._get_user_context(user_id) + if not isinstance(context, dict): + return + generic_memory = context.get("generic_memory") + if not isinstance(generic_memory, dict): + generic_memory = {} + context["generic_memory"] = generic_memory + if generic_memory.get("orcamento_max"): + return + budget = extract_budget_from_text(message) + if budget: + generic_memory["orcamento_max"] = int(round(budget)) + context.setdefault("shared_memory", {})["orcamento_max"] = int(round(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 @@ -390,6 +414,8 @@ class OrderFlowMixin: } draft["payload"].update(extracted) + self._try_capture_order_cpf_from_message(message=message, payload=draft["payload"]) + self._try_capture_order_budget_from_message(user_id=user_id, message=message, 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"]) @@ -437,7 +463,7 @@ class OrderFlowMixin: user_id=user_id, payload=draft["payload"], turn_decision=turn_decision, - force=has_intent or explicit_order_request, + force=bool(draft) or has_intent or explicit_order_request, ) if stock_response: return stock_response diff --git a/app/services/orchestration/technical_normalizer.py b/app/services/orchestration/technical_normalizer.py index 2fd6fda..5c091f8 100644 --- a/app/services/orchestration/technical_normalizer.py +++ b/app/services/orchestration/technical_normalizer.py @@ -28,6 +28,14 @@ def normalize_cpf(value) -> str | None: return None +def extract_cpf_from_text(text: str) -> str | None: + for match in re.finditer(r"(? bool: digits = normalize_cpf(value) if not digits: @@ -69,6 +77,31 @@ def normalize_positive_number(value) -> float | None: return None +def extract_budget_from_text(text: str) -> float | None: + candidate = str(text or "") + if not candidate.strip(): + return None + + mil_match = re.search(r"(? list[str]: if value is None: return [] diff --git a/tests/test_conversation_adjustments.py b/tests/test_conversation_adjustments.py index b78dd59..84d6f3f 100644 --- a/tests/test_conversation_adjustments.py +++ b/tests/test_conversation_adjustments.py @@ -9,6 +9,7 @@ from fastapi import HTTPException from app.services.flows.order_flow import OrderFlowMixin from app.services.flows.review_flow import ReviewFlowMixin +from app.integrations.telegram_satellite_service import _ensure_supported_runtime_configuration 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 @@ -202,6 +203,21 @@ class ReviewFlowHarness(ReviewFlowMixin): class ConversationAdjustmentsTests(unittest.TestCase): + def test_telegram_satellite_requires_redis_in_production(self): + with patch("app.integrations.telegram_satellite_service.settings.environment", "production"), patch( + "app.integrations.telegram_satellite_service.settings.conversation_state_backend", + "memory", + ): + with self.assertRaises(RuntimeError): + _ensure_supported_runtime_configuration() + + def test_telegram_satellite_allows_redis_in_production(self): + with patch("app.integrations.telegram_satellite_service.settings.environment", "production"), patch( + "app.integrations.telegram_satellite_service.settings.conversation_state_backend", + "redis", + ): + _ensure_supported_runtime_configuration() + def test_defer_flow_cancel_when_order_cancel_draft_waits_for_reason(self): state = FakeState( entries={ @@ -361,6 +377,77 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase): self.assertIn("Encontrei 2 veiculo(s):", response) self.assertIn("Honda Civic 2021", response) + async def test_order_flow_extracts_budget_from_message_when_llm_misses_it(self): + state = FakeState( + contexts={ + 10: { + "generic_memory": {}, + "shared_memory": {}, + "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 50 mil, meu CPF e 12345678909", + user_id=10, + extracted_fields={}, + intents={}, + turn_decision={"intent": "order_create", "domain": "sales", "action": "collect_order_create"}, + ) + + self.assertIn("Encontrei 2 veiculo(s):", response) + self.assertEqual(state.get_user_context(10)["generic_memory"]["orcamento_max"], 50000) + + async def test_order_flow_extracts_cpf_from_followup_message_when_llm_misses_it(self): + state = FakeState( + entries={ + "pending_order_drafts": { + 10: { + "payload": {}, + "expires_at": datetime.utcnow() + timedelta(minutes=30), + } + } + }, + contexts={ + 10: { + "generic_memory": {"orcamento_max": 50000}, + "shared_memory": {"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="Meu CPF e 12345678909", + user_id=10, + extracted_fields={}, + intents={}, + ) + + self.assertIn("Encontrei 2 veiculo(s):", response) + self.assertEqual(registry.calls[0][0], "consultar_estoque") + async def test_order_flow_lists_stock_from_budget_when_vehicle_is_missing(self): state = FakeState( entries={