From 9b6b2a643bb348855b8cea48e5f0e146c15d1c14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vitor=20Hugo=20Belorio=20Sim=C3=A3o?= Date: Mon, 16 Mar 2026 16:35:23 -0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(review):=20estabilizar=20fol?= =?UTF-8?q?low-ups=20curtos=20e=20remarcacao=20incremental=20no=20Telegram?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - consome follow-ups curtos de revisao aberta antes do LLM para preservar data, horario e rascunhos ativos entre mensagens - melhora a extracao de modelo a partir de resumos curtos e aceita respostas isoladas como Onix quando esse eh o ultimo campo faltante - faz a remarcacao aceitar amanha 11h ou amanha seguido de 11h sem cair em um novo agendamento de revisao - prioriza review_reschedule, review_cancel e review_list sobre respostas livres do modelo e amplia a cobertura de regressao da orquestracao --- app/services/flows/review_flow.py | 126 ++++++- .../orchestration/orquestrador_service.py | 108 ++++++ tests/test_conversation_adjustments.py | 139 ++++++++ tests/test_turn_decision_contract.py | 327 ++++++++++++++++++ 4 files changed, 688 insertions(+), 12 deletions(-) diff --git a/app/services/flows/review_flow.py b/app/services/flows/review_flow.py index 896f2fa..5903b39 100644 --- a/app/services/flows/review_flow.py +++ b/app/services/flows/review_flow.py @@ -145,6 +145,77 @@ class ReviewFlowMixin: return "general" return str(context.get("active_domain") or "general").strip().lower() + def _clean_review_model_candidate(self, raw_model: str | None) -> str | None: + text = str(raw_model or "").strip(" ,.;:-") + if not text: + return None + text = re.sub(r"\s+", " ", text) + text = re.sub(r"\be\b$", "", text).strip(" ,.;:-") + if not text: + return None + stop_terms = { + "amanha", + "hoje", + "revisao", + "agendar", + "marcar", + "cancelar", + "pedido", + "sim", + "nao", + "ok", + "pode", + } + lowered = text.lower() + if lowered in stop_terms: + return None + if any(term in lowered for term in {"agendar revisao", "marcar revisao", "cancelar revisao"}): + return None + if not re.search(r"[a-z]", lowered): + return None + if len(text.split()) > 4: + return None + return text.title() + + def _extract_review_model_from_message(self, normalized_message: str) -> str | None: + explicit_match = re.search( + r"(?:modelo do meu carro (?:e|eh)?|meu carro (?:e|eh)?|carro (?:e|eh)?|veiculo (?:e|eh)?)\s+([a-z0-9][a-z0-9\s-]{1,30})", + normalized_message, + flags=re.IGNORECASE, + ) + if explicit_match: + raw_model = explicit_match.group(1) + raw_model = re.split(r"\b(?:ele e|ele eh|ano|placa|km|quilometragem|data|amanha|hoje)\b", raw_model, maxsplit=1)[0] + return self._clean_review_model_candidate(raw_model) + + has_year = bool(re.search(r"(? None: if not isinstance(payload, dict): return @@ -185,18 +256,9 @@ class ReviewFlowMixin: payload["ano"] = int(year_match.group(1)) if "modelo" not in payload: - model_match = re.search( - r"(?:modelo do meu carro (?:e|eh)?|meu carro (?:e|eh)?|carro (?:e|eh)?|veiculo (?:e|eh)?)\s+([a-z0-9][a-z0-9\s-]{1,30})", - normalized_message, - flags=re.IGNORECASE, - ) - if model_match: - raw_model = model_match.group(1) - raw_model = re.split(r"\b(?:ele e|ele eh|ano|placa|km|quilometragem|data|amanha|hoje)\b", raw_model, maxsplit=1)[0] - raw_model = raw_model.strip(" ,.;:-") - raw_model = re.sub(r"\be\b$", "", raw_model).strip(" ,.;:-") - if raw_model: - payload["modelo"] = raw_model.title() + extracted_model = self._extract_review_model_from_message(normalized_message) + if extracted_model: + payload["modelo"] = extracted_model def _extract_review_date_only_text(self, message: str) -> str | None: text = self.normalizer.normalize_datetime_connector(message) @@ -241,6 +303,33 @@ class ReviewFlowMixin: return payload["data_hora_base"] = date_only + def _extract_review_management_datetime_from_message(self, message: str) -> str | None: + return self.normalizer.normalize_review_datetime_text( + message, + now_provider=self._review_now, + ) + + def _merge_review_management_base_date_with_time(self, message: str, payload: dict) -> None: + if not isinstance(payload, dict): + return + if payload.get("nova_data_hora") or not payload.get("nova_data_hora_base"): + return + time_text = self.normalizer.extract_hhmm_from_text(message) + if not time_text: + return + payload["nova_data_hora"] = f"{payload['nova_data_hora_base']} {time_text}" + payload.pop("nova_data_hora_base", None) + + def _store_review_management_base_date_from_message(self, message: str, payload: dict) -> None: + if not isinstance(payload, dict): + return + if payload.get("nova_data_hora") or payload.get("nova_data_hora_base"): + return + date_only = self._extract_review_date_only_text(message) + if not date_only: + return + payload["nova_data_hora_base"] = date_only + def _is_review_temporal_follow_up(self, message: str, payload: dict | None) -> bool: if not isinstance(payload, dict): return False @@ -396,6 +485,10 @@ class ReviewFlowMixin: extracted["protocolo"] = inferred_protocol action = draft.get("action", "cancel") + if action == "reschedule" and "nova_data_hora" not in extracted: + normalized_new_datetime = self._extract_review_management_datetime_from_message(message) + if normalized_new_datetime: + extracted["nova_data_hora"] = normalized_new_datetime if ( action == "cancel" and "motivo" not in extracted @@ -407,6 +500,10 @@ class ReviewFlowMixin: extracted["motivo"] = free_text draft["payload"].update(extracted) + if action == "reschedule": + self._merge_review_management_base_date_with_time(message=message, payload=draft["payload"]) + if "nova_data_hora" not in draft["payload"]: + self._store_review_management_base_date_from_message(message=message, payload=draft["payload"]) draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES) self._set_review_flow_entry( "pending_review_management_drafts", @@ -419,6 +516,11 @@ class ReviewFlowMixin: if action == "reschedule": missing = [field for field in ("protocolo", "nova_data_hora") if field not in draft["payload"]] if missing: + if missing == ["nova_data_hora"] and draft["payload"].get("nova_data_hora_base"): + return ( + f"Perfeito. Tenho a data {draft['payload']['nova_data_hora_base']}. " + "Agora me informe o horario desejado para a revisao." + ) return self._render_missing_review_reschedule_fields_prompt(missing) try: tool_result = await self.tool_executor.execute( diff --git a/app/services/orchestration/orquestrador_service.py b/app/services/orchestration/orquestrador_service.py index 9abcfb3..9b57575 100644 --- a/app/services/orchestration/orquestrador_service.py +++ b/app/services/orchestration/orquestrador_service.py @@ -112,6 +112,13 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): ) if active_sales_follow_up: return active_sales_follow_up + active_review_follow_up = await self._try_handle_active_review_follow_up( + message=message, + user_id=user_id, + finish=finish, + ) + if active_review_follow_up: + return active_review_follow_up # Faz uma leitura inicial do turno para ajudar a policy # com fila, troca de contexto e comandos globais. early_turn_decision = await self._extract_turn_decision_with_llm( @@ -231,6 +238,10 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): user_id=user_id, message=routing_message, ) + should_prioritize_review_management = self._should_prioritize_review_management( + turn_decision=turn_decision, + user_id=user_id, + ) domain_hint = self._domain_from_turn_decision(turn_decision) if domain_hint == "general": domain_hint = self._domain_from_intents(extracted_entities.get("intents", {})) @@ -268,6 +279,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): decision_action == "ask_missing_fields" and decision_response and not should_prioritize_review_flow + and not should_prioritize_review_management and not should_prioritize_order_flow ): return await finish(decision_response, queue_notice=queue_notice) @@ -275,6 +287,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): decision_action == "answer_user" and decision_response and not should_prioritize_review_flow + and not should_prioritize_review_management and not should_prioritize_order_flow ): return await finish(decision_response, queue_notice=queue_notice) @@ -642,6 +655,86 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): return None + async def _try_handle_active_review_follow_up( + self, + message: str, + user_id: int | None, + finish, + ) -> str | None: + if user_id is None: + return None + context = self._get_user_context(user_id) + if not isinstance(context, dict): + return None + if str(context.get("active_domain") or "").strip().lower() != "review": + return None + + if ( + self._has_explicit_order_request(message) + or self._has_order_listing_request(message) + or self._has_stock_listing_request(message) + ): + return None + + pending_management_draft = self.state.get_entry( + "pending_review_management_drafts", + user_id, + expire=True, + ) + if pending_management_draft: + management_action = "review_reschedule" + if str(pending_management_draft.get("action") or "").strip().lower() == "cancel": + management_action = "review_cancel" + response = await self._try_handle_review_management( + message=message, + user_id=user_id, + extracted_fields={}, + intents={}, + turn_decision={ + "intent": management_action, + "domain": "review", + "action": "answer_user", + }, + ) + if response: + return await finish(response) + + pending_review_confirmation = self.state.get_entry( + "pending_review_confirmations", + user_id, + expire=True, + ) + if pending_review_confirmation: + response = await self._try_confirm_pending_review( + message=message, + user_id=user_id, + extracted_review_fields={}, + ) + if response: + return await finish(response) + + has_open_review_schedule = bool( + self.state.get_entry("pending_review_drafts", user_id, expire=True) + or self.state.get_entry("pending_review_reuse_confirmations", user_id, expire=True) + ) + if not has_open_review_schedule: + return None + + response = await self._try_collect_and_schedule_review( + message=message, + user_id=user_id, + extracted_fields={}, + intents={}, + turn_decision={ + "intent": "review_schedule", + "domain": "review", + "action": "collect_review_schedule", + }, + ) + if not response: + return None + return await finish(response) + async def _try_execute_business_tool_from_turn_decision( self, message: str, @@ -1318,6 +1411,21 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): ) return any(term in normalized_message for term in shift_terms) + def _should_prioritize_review_management( + self, + turn_decision: dict | None, + user_id: int | None = None, + ) -> bool: + has_open_management_draft = bool( + user_id is not None + and self.state.get_entry("pending_review_management_drafts", user_id, expire=True) + ) + if has_open_management_draft: + return True + + decision_intent = str((turn_decision or {}).get("intent") or "").strip().lower() + return decision_intent in {"review_list", "review_cancel", "review_reschedule"} + def _should_prioritize_review_flow( self, turn_decision: dict | None, diff --git a/tests/test_conversation_adjustments.py b/tests/test_conversation_adjustments.py index b386bef..ecb1109 100644 --- a/tests/test_conversation_adjustments.py +++ b/tests/test_conversation_adjustments.py @@ -1584,6 +1584,73 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(arguments.get("revisao_previa_concessionaria")) self.assertIn("REV-TESTE-123", response) + async def test_review_flow_extracts_short_vehicle_summary_from_free_text(self): + state = FakeState( + entries={ + "pending_review_drafts": { + 21: { + "payload": {"placa": "ABC1269", "data_hora": "13/03/2026 16:00"}, + "expires_at": utc_now() + timedelta(minutes=30), + } + } + } + ) + registry = FakeRegistry() + flow = ReviewFlowHarness(state=state, registry=registry) + + response = await flow._try_collect_and_schedule_review( + message="Onix 2024, 12000 km, nao fiz revisao na concessionaria", + user_id=21, + extracted_fields={}, + intents={}, + turn_decision={"intent": "review_schedule", "domain": "review", "action": "answer_user"}, + ) + + self.assertIsNone(state.get_entry("pending_review_drafts", 21)) + self.assertEqual(registry.calls[0][0], "agendar_revisao") + _, arguments, tool_user_id = registry.calls[0] + self.assertEqual(tool_user_id, 21) + self.assertEqual(arguments.get("modelo"), "Onix") + self.assertEqual(arguments.get("ano"), 2024) + self.assertEqual(arguments.get("km"), 12000) + self.assertFalse(arguments.get("revisao_previa_concessionaria")) + self.assertIn("REV-TESTE-123", response) + + async def test_review_flow_accepts_bare_model_when_it_is_last_missing_field(self): + state = FakeState( + entries={ + "pending_review_drafts": { + 21: { + "payload": { + "placa": "ABC1269", + "data_hora": "13/03/2026 16:00", + "ano": 2024, + "km": 12000, + "revisao_previa_concessionaria": False, + }, + "expires_at": utc_now() + timedelta(minutes=30), + } + } + } + ) + registry = FakeRegistry() + flow = ReviewFlowHarness(state=state, registry=registry) + + response = await flow._try_collect_and_schedule_review( + message="Onix", + user_id=21, + extracted_fields={}, + intents={}, + turn_decision={"intent": "review_schedule", "domain": "review", "action": "answer_user"}, + ) + + self.assertIsNone(state.get_entry("pending_review_drafts", 21)) + self.assertEqual(registry.calls[0][0], "agendar_revisao") + _, arguments, tool_user_id = registry.calls[0] + self.assertEqual(tool_user_id, 21) + self.assertEqual(arguments.get("modelo"), "Onix") + self.assertIn("REV-TESTE-123", response) + async def test_review_flow_keeps_plate_and_datetime_across_incremental_messages(self): fixed_now = lambda: datetime(2026, 3, 12, 9, 0) state = FakeState() @@ -2199,6 +2266,78 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase): self.assertIn("cancelar_agendamento_revisao", response) self.assertIn("REV-20260313-F754AF27", response) + async def test_review_management_reschedule_consumes_relative_datetime_follow_up(self): + fixed_now = lambda: datetime(2026, 3, 12, 9, 0) + state = FakeState( + entries={ + "pending_review_management_drafts": { + 21: { + "action": "reschedule", + "payload": {"protocolo": "REV-20260313-F754AF27"}, + "expires_at": utc_now() + timedelta(minutes=30), + } + } + } + ) + registry = FakeRegistry() + flow = ReviewFlowHarness(state=state, registry=registry, review_now_provider=fixed_now) + + response = await flow._try_handle_review_management( + message="amanha 11h", + user_id=21, + extracted_fields={}, + intents={}, + turn_decision={"intent": "review_reschedule", "domain": "review", "action": "answer_user"}, + ) + + self.assertIsNone(state.get_entry("pending_review_management_drafts", 21)) + self.assertEqual(registry.calls[0][0], "editar_data_revisao") + self.assertEqual(registry.calls[0][1]["protocolo"], "REV-20260313-F754AF27") + self.assertEqual(registry.calls[0][1]["nova_data_hora"], "13/03/2026 11:00") + self.assertIn("13/03/2026 11:00", response) + + async def test_review_management_reschedule_date_only_then_time_follow_up(self): + fixed_now = lambda: datetime(2026, 3, 12, 9, 0) + state = FakeState( + entries={ + "pending_review_management_drafts": { + 21: { + "action": "reschedule", + "payload": {"protocolo": "REV-20260313-F754AF27"}, + "expires_at": utc_now() + timedelta(minutes=30), + } + } + } + ) + registry = FakeRegistry() + flow = ReviewFlowHarness(state=state, registry=registry, review_now_provider=fixed_now) + + first_response = await flow._try_handle_review_management( + message="amanha", + user_id=21, + extracted_fields={}, + intents={}, + turn_decision={"intent": "review_reschedule", "domain": "review", "action": "answer_user"}, + ) + + draft = state.get_entry("pending_review_management_drafts", 21) + self.assertIsNotNone(draft) + self.assertEqual(draft["payload"].get("nova_data_hora_base"), "13/03/2026") + self.assertIn("Perfeito. Tenho a data 13/03/2026.", first_response) + + second_response = await flow._try_handle_review_management( + message="11h", + user_id=21, + extracted_fields={}, + intents={}, + turn_decision={"intent": "review_reschedule", "domain": "review", "action": "answer_user"}, + ) + + self.assertIsNone(state.get_entry("pending_review_management_drafts", 21)) + self.assertEqual(registry.calls[0][0], "editar_data_revisao") + self.assertEqual(registry.calls[0][1]["nova_data_hora"], "13/03/2026 11:00") + self.assertIn("13/03/2026 11:00", second_response) + async def test_review_management_infers_listing_intent_from_agendamentos_message(self): state = FakeState() registry = FakeRegistry() diff --git a/tests/test_turn_decision_contract.py b/tests/test_turn_decision_contract.py index 6c6ed8c..1377c9b 100644 --- a/tests/test_turn_decision_contract.py +++ b/tests/test_turn_decision_contract.py @@ -1312,6 +1312,140 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertTrue(prioritized) + async def test_handle_message_prioritizes_review_management_over_model_answer_for_reschedule_intent(self): + state = FakeState( + contexts={ + 1: { + "active_domain": "general", + "generic_memory": {}, + "shared_memory": {}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, + } + } + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service.policy = ConversationPolicy(service=service) + service._empty_extraction_payload = service.normalizer.empty_extraction_payload + service._log_turn_event = lambda *args, **kwargs: None + service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response + + async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None): + return base_response + + service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order + service._upsert_user_context = lambda user_id: None + + async def fake_extract_turn_decision(message: str, user_id: int | None): + return { + "intent": "review_reschedule", + "domain": "review", + "action": "answer_user", + "entities": service.normalizer.empty_extraction_payload(), + "missing_fields": [], + "selection_index": None, + "tool_name": None, + "tool_arguments": {}, + "response_to_user": "Claro, para qual data e horario voce gostaria de remarcar?", + } + + service._extract_turn_decision_with_llm = fake_extract_turn_decision + + async def fake_try_handle_immediate_context_reset(**kwargs): + return None + + service._try_handle_immediate_context_reset = fake_try_handle_immediate_context_reset + + async def fake_try_resolve_pending_order_selection(**kwargs): + return None + + service._try_resolve_pending_order_selection = fake_try_resolve_pending_order_selection + + async def fake_try_continue_queued_order(**kwargs): + return None + + service._try_continue_queued_order = fake_try_continue_queued_order + + async def fake_extract_message_plan(message: str, user_id: int | None): + return { + "orders": [ + { + "domain": "review", + "message": message, + "entities": service.normalizer.empty_extraction_payload(), + } + ] + } + + service._extract_message_plan_with_llm = fake_extract_message_plan + service._prepare_message_for_single_order = lambda message, user_id, routing_plan=None: (message, None, None) + service._resolve_entities_for_message_plan = lambda message_plan, routed_message: service.normalizer.empty_extraction_payload() + + async def fake_extract_entities(message: str, user_id: int | None): + return service.normalizer.empty_extraction_payload() + + service._extract_entities_with_llm = fake_extract_entities + + async def fake_extract_missing_sales_search_context_with_llm(**kwargs): + return {} + + service._extract_missing_sales_search_context_with_llm = fake_extract_missing_sales_search_context_with_llm + service._domain_from_intents = lambda intents: "general" + service._handle_context_switch = lambda **kwargs: None + service._update_active_domain = lambda **kwargs: None + + async def fake_try_execute_orchestration_control_tool(**kwargs): + return None + + service._try_execute_orchestration_control_tool = fake_try_execute_orchestration_control_tool + + async def fake_try_execute_business_tool_from_turn_decision(**kwargs): + return None + + service._try_execute_business_tool_from_turn_decision = fake_try_execute_business_tool_from_turn_decision + + async def fake_try_handle_review_management(**kwargs): + return "Para remarcar sua revisao, preciso dos dados abaixo:\n- a nova data e hora desejada para a revisao" + + service._try_handle_review_management = fake_try_handle_review_management + + async def fake_try_confirm_pending_review(**kwargs): + return None + + service._try_confirm_pending_review = fake_try_confirm_pending_review + + async def fake_try_collect_and_schedule_review(**kwargs): + return None + + service._try_collect_and_schedule_review = fake_try_collect_and_schedule_review + + async def fake_try_collect_and_cancel_order(**kwargs): + return None + + service._try_collect_and_cancel_order = fake_try_collect_and_cancel_order + + async def fake_try_handle_order_listing(**kwargs): + return None + + service._try_handle_order_listing = fake_try_handle_order_listing + + async def fake_try_collect_and_create_order(**kwargs): + return None + + service._try_collect_and_create_order = fake_try_collect_and_create_order + + response = await service.handle_message( + "quero remarcar o meu agendamento REV-20260317-54E9D3CB", + user_id=1, + ) + + self.assertIn("a nova data e hora desejada", response) + async def test_handle_message_prioritizes_review_flow_over_model_answer_for_followup(self): state = FakeState( entries={ @@ -1832,6 +1966,199 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(response, "Pedido criado com sucesso.") + async def test_handle_message_short_circuits_active_review_time_follow_up_before_llm(self): + state = FakeState( + entries={ + "pending_review_drafts": { + 1: { + "payload": { + "placa": "ABC1234", + "data_hora_base": "17/03/2026", + }, + "expires_at": utc_now() + timedelta(minutes=15), + } + } + }, + contexts={ + 1: { + "active_domain": "review", + "generic_memory": {"placa": "ABC1234"}, + "shared_memory": {"placa": "ABC1234"}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, + } + } + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service._empty_extraction_payload = service.normalizer.empty_extraction_payload + service._log_turn_event = lambda *args, **kwargs: None + service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response + service._get_user_context = lambda user_id: state.get_user_context(user_id) + service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context) + + async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None): + return base_response + + service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order + service._upsert_user_context = lambda user_id: None + + async def fake_try_handle_pending_stock_selection_follow_up(**kwargs): + return None + + service._try_handle_pending_stock_selection_follow_up = fake_try_handle_pending_stock_selection_follow_up + + async def fake_extract_turn_decision(message: str, user_id: int | None): + raise AssertionError("nao deveria consultar o LLM para um follow-up temporal de revisao com draft aberto") + + service._extract_turn_decision_with_llm = fake_extract_turn_decision + + async def fake_try_collect_and_schedule_review(**kwargs): + self.assertEqual(kwargs["turn_decision"]["intent"], "review_schedule") + return "Para agendar sua revisao, preciso dos dados abaixo:\n- o modelo do veiculo" + + service._try_collect_and_schedule_review = fake_try_collect_and_schedule_review + + async def fake_try_handle_review_management(**kwargs): + return None + + service._try_handle_review_management = fake_try_handle_review_management + + async def fake_try_confirm_pending_review(**kwargs): + return None + + service._try_confirm_pending_review = fake_try_confirm_pending_review + + response = await service.handle_message( + "15h", + user_id=1, + ) + + self.assertIn("o modelo do veiculo", response) + + async def test_handle_message_allows_explicit_sales_shift_before_active_review_follow_up_short_circuit(self): + state = FakeState( + entries={ + "pending_review_drafts": { + 1: { + "payload": { + "placa": "ABC1234", + "data_hora_base": "17/03/2026", + }, + "expires_at": utc_now() + timedelta(minutes=15), + } + } + }, + contexts={ + 1: { + "active_domain": "review", + "generic_memory": {"placa": "ABC1234"}, + "shared_memory": {"placa": "ABC1234"}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, + } + } + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service._empty_extraction_payload = service.normalizer.empty_extraction_payload + service._log_turn_event = lambda *args, **kwargs: None + service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response + service._get_user_context = lambda user_id: state.get_user_context(user_id) + service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context) + + async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None): + return base_response + + service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order + service._upsert_user_context = lambda user_id: None + + async def fake_try_handle_pending_stock_selection_follow_up(**kwargs): + return None + + service._try_handle_pending_stock_selection_follow_up = fake_try_handle_pending_stock_selection_follow_up + + async def fake_try_handle_immediate_context_reset(**kwargs): + return None + + service._try_handle_immediate_context_reset = fake_try_handle_immediate_context_reset + + async def fake_try_resolve_pending_order_selection(**kwargs): + return None + + service._try_resolve_pending_order_selection = fake_try_resolve_pending_order_selection + + async def fake_try_continue_queued_order(**kwargs): + return None + + service._try_continue_queued_order = fake_try_continue_queued_order + + async def fake_extract_turn_decision(message: str, user_id: int | None): + return { + "intent": "order_create", + "domain": "sales", + "action": "collect_order_create", + "entities": service.normalizer.empty_extraction_payload(), + "missing_fields": [], + "selection_index": None, + "tool_name": None, + "tool_arguments": {}, + "response_to_user": "", + } + + service._extract_turn_decision_with_llm = fake_extract_turn_decision + + async def fake_extract_message_plan(message: str, user_id: int | None): + return { + "orders": [ + { + "domain": "sales", + "message": message, + "entities": service.normalizer.empty_extraction_payload(), + } + ] + } + + service._extract_message_plan_with_llm = fake_extract_message_plan + service._prepare_message_for_single_order = lambda message, user_id, routing_plan=None: (message, None, None) + service._resolve_entities_for_message_plan = lambda message_plan, routed_message: service.normalizer.empty_extraction_payload() + + async def fake_extract_entities(message: str, user_id: int | None): + return service.normalizer.empty_extraction_payload() + + service._extract_entities_with_llm = fake_extract_entities + + async def fake_extract_missing_sales_search_context_with_llm(**kwargs): + return {} + + service._extract_missing_sales_search_context_with_llm = fake_extract_missing_sales_search_context_with_llm + service._domain_from_intents = lambda intents: "general" + service._update_active_domain = lambda **kwargs: None + service._handle_context_switch = lambda **kwargs: "Entendi que voce quer sair de agendamento de revisao e ir para compra de veiculo. Tem certeza?" + + async def fake_try_collect_and_schedule_review(**kwargs): + raise AssertionError("nao deveria consumir uma solicitacao explicita de compra como follow-up de revisao") + + service._try_collect_and_schedule_review = fake_try_collect_and_schedule_review + + response = await service.handle_message( + "quero comprar um carro ate 80 mil", + user_id=1, + ) + + self.assertEqual( + response, + "Entendi que voce quer sair de agendamento de revisao e ir para compra de veiculo. Tem certeza?", + ) + async def test_handle_message_prioritizes_pending_switch_confirmation_before_sales_follow_up(self): state = FakeState( entries={