From 21661e83068c6166cbb5b313212d616fcb843e9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vitor=20Hugo=20Belorio=20Sim=C3=A3o?= Date: Wed, 11 Mar 2026 15:56:30 -0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(orchestration):=20priorizar?= =?UTF-8?q?=20fluxo=20de=20compra=20sobre=20resposta=20livre=20do=20modelo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - evitar que respostas answer_user ou ask_missing_fields do modelo interrompam compras ja caracterizadas - manter o order_flow como caminho deterministico quando cpf, orcamento ou perfil ja permitem avancar - preservar a arquitetura com o modelo decidindo o turno e o backend apenas coordenando a continuidade - cobrir com teste de regressao o caso reproduzido no servidor para compra com orcamento e cpf --- .../orchestration/orquestrador_service.py | 35 ++++- tests/test_turn_decision_contract.py | 140 ++++++++++++++++++ 2 files changed, 173 insertions(+), 2 deletions(-) diff --git a/app/services/orchestration/orquestrador_service.py b/app/services/orchestration/orquestrador_service.py index 65620e1..3018a8a 100644 --- a/app/services/orchestration/orquestrador_service.py +++ b/app/services/orchestration/orquestrador_service.py @@ -203,9 +203,13 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): decision_action = str(turn_decision.get("action") or "") decision_response = str(turn_decision.get("response_to_user") or "").strip() - if decision_action == "ask_missing_fields" and decision_response: + should_prioritize_order_flow = self._should_prioritize_order_flow( + turn_decision=turn_decision, + extracted_entities=extracted_entities, + ) + if decision_action == "ask_missing_fields" and decision_response and not should_prioritize_order_flow: return await finish(decision_response, queue_notice=queue_notice) - if decision_action == "answer_user" and decision_response: + if decision_action == "answer_user" and decision_response and not should_prioritize_order_flow: return await finish(decision_response, queue_notice=queue_notice) planned_tool_response = await self._try_execute_business_tool_from_turn_decision( @@ -909,6 +913,33 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): return domain return "general" + def _should_prioritize_order_flow( + self, + turn_decision: dict | None, + extracted_entities: dict | None, + ) -> bool: + decision = turn_decision or {} + if str(decision.get("intent") or "").strip().lower() != "order_create": + return False + + entities = extracted_entities if isinstance(extracted_entities, dict) else {} + generic_memory = entities.get("generic_memory") + order_fields = entities.get("order_fields") + if not isinstance(generic_memory, dict): + generic_memory = {} + if not isinstance(order_fields, dict): + order_fields = {} + + return any( + ( + order_fields.get("vehicle_id"), + order_fields.get("cpf"), + generic_memory.get("cpf"), + generic_memory.get("orcamento_max"), + generic_memory.get("perfil_veiculo"), + ) + ) + def _parse_json_object(self, text: str): return self.normalizer.parse_json_object(text) diff --git a/tests/test_turn_decision_contract.py b/tests/test_turn_decision_contract.py index f062fd8..7184e28 100644 --- a/tests/test_turn_decision_contract.py +++ b/tests/test_turn_decision_contract.py @@ -43,6 +43,11 @@ class FakeState: return None return self.entries.get(bucket, {}).pop(user_id, None) + def get_user_context(self, user_id: int | None): + if user_id is None: + return None + return self.contexts.get(user_id) + class FakeToolExecutor: def __init__(self, result=None): @@ -427,6 +432,141 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(str(decision.get("action") or ""), "answer_user") self.assertEqual(str(decision.get("response_to_user") or "").strip(), "Resposta direta do contrato.") + async def test_handle_message_prioritizes_order_flow_over_model_answer_for_purchase_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._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": "order_create", + "domain": "sales", + "action": "answer_user", + "entities": { + "generic_memory": {"cpf": "12345678909", "orcamento_max": 70000}, + "review_fields": {}, + "review_management_fields": {}, + "order_fields": {}, + "cancel_order_fields": {}, + }, + "missing_fields": [], + "selection_index": None, + "tool_name": None, + "tool_arguments": {}, + "response_to_user": "Certo! Para te ajudar a encontrar o carro ideal dentro do seu orcamento.", + } + + service._extract_turn_decision_with_llm = fake_extract_turn_decision + 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": "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 { + "generic_memory": {"cpf": "12345678909", "orcamento_max": 70000}, + "review_fields": {}, + "review_management_fields": {}, + "order_fields": {}, + "cancel_order_fields": {}, + "intents": {}, + } + + 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 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 + + 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_collect_and_create_order(**kwargs): + return "Encontrei 2 veiculo(s):\n1. Hyundai HB20 2022" + + service._try_collect_and_create_order = fake_try_collect_and_create_order + + response = await service.handle_message( + "Quero comprar um carro de 70 mil, meu CPF e 12345678909", + user_id=1, + ) + + self.assertIn("Encontrei 2 veiculo(s):", response) + async def test_pending_order_selection_prefers_turn_decision_domain(self): state = FakeState( contexts={