diff --git a/app/services/orchestration/entity_normalizer.py b/app/services/orchestration/entity_normalizer.py index 008dc4d..fff3a8d 100644 --- a/app/services/orchestration/entity_normalizer.py +++ b/app/services/orchestration/entity_normalizer.py @@ -338,7 +338,7 @@ class EntityNormalizer: stripped = re.sub(r"\s*```$", "", stripped) return stripped.strip() - def _try_parse_json_candidate(self, candidate: str): + def _try_parse_json_candidate(self, candidate: str, depth: int = 0): normalized = str(candidate or "").strip() if not normalized: return None @@ -359,15 +359,32 @@ class EntityNormalizer: for variant in variants: try: - return json.loads(variant) + parsed = json.loads(variant) except json.JSONDecodeError: - pass + parsed = None + coerced = self._coerce_parsed_json_candidate(parsed, depth=depth) + if isinstance(coerced, dict): + return coerced try: parsed = ast.literal_eval(variant) except (ValueError, SyntaxError): continue - if isinstance(parsed, dict): - return parsed + coerced = self._coerce_parsed_json_candidate(parsed, depth=depth) + if isinstance(coerced, dict): + return coerced + return None + + def _coerce_parsed_json_candidate(self, parsed, depth: int = 0): + if isinstance(parsed, dict): + return parsed + if depth >= 2: + return None + if isinstance(parsed, list) and len(parsed) == 1: + return self._coerce_parsed_json_candidate(parsed[0], depth=depth + 1) + if isinstance(parsed, str): + nested = parsed.strip() + if nested: + return self._try_parse_json_candidate(nested, depth=depth + 1) return None def coerce_turn_decision(self, payload) -> dict: @@ -431,7 +448,12 @@ class EntityNormalizer: normalized["tool_name"] = tool_name or None tool_arguments = normalized.get("tool_arguments") if tool_name and isinstance(tool_arguments, dict): - normalized["tool_arguments"] = self.normalize_tool_arguments(tool_name, tool_arguments) + merged_tool_arguments = self._merge_tool_arguments_from_turn_entities( + tool_name=tool_name, + tool_arguments=tool_arguments, + entities=normalized.get("entities"), + ) + normalized["tool_arguments"] = self.normalize_tool_arguments(tool_name, merged_tool_arguments) else: normalized["tool_arguments"] = tool_arguments if isinstance(tool_arguments, dict) else {} @@ -707,6 +729,28 @@ class EntityNormalizer: return payload + def _merge_tool_arguments_from_turn_entities(self, tool_name: str | None, tool_arguments: dict, entities: dict | None) -> dict: + merged_arguments = dict(tool_arguments or {}) + normalized_tool_name = self.normalize_tool_name(tool_name) + if normalized_tool_name != "consultar_estoque": + return merged_arguments + + generic_memory = (entities or {}).get("generic_memory") if isinstance(entities, dict) else {} + if not isinstance(generic_memory, dict): + return merged_arguments + + if merged_arguments.get("preco_max") in (None, "", [], {}): + budget = generic_memory.get("orcamento_max") + if budget not in (None, "", [], {}): + merged_arguments["preco_max"] = budget + + if merged_arguments.get("categoria") in (None, "", [], {}): + profiles = self.normalize_vehicle_profile(generic_memory.get("perfil_veiculo")) + if len(profiles) == 1: + merged_arguments["categoria"] = profiles[0] + + return merged_arguments + def _normalize_turn_missing_fields(self, missing_fields) -> list[str]: if missing_fields is None: return [] diff --git a/app/services/orchestration/orquestrador_service.py b/app/services/orchestration/orquestrador_service.py index 19df212..aebef71 100644 --- a/app/services/orchestration/orquestrador_service.py +++ b/app/services/orchestration/orquestrador_service.py @@ -230,29 +230,27 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin): return deterministic_rental_bootstrap # Faz uma leitura inicial do turno para ajudar a policy # com fila, troca de contexto e comandos globais. - turn_bundle = await self._extract_turn_bundle_with_llm( + early_turn_decision = await self._extract_turn_decision_with_llm( message=message, user_id=user_id, ) - bundle_has_useful_turn_decision = ( - isinstance(turn_bundle, dict) - and bool(turn_bundle.get("has_turn_decision")) - and isinstance(turn_bundle.get("turn_decision"), dict) - and self._has_useful_turn_decision(turn_bundle.get("turn_decision")) + use_turn_bundle = self._should_attempt_turn_bundle( + message=message, + early_turn_decision=early_turn_decision, + ) + turn_bundle = ( + await self._extract_turn_bundle_with_llm( + message=message, + user_id=user_id, + ) + if use_turn_bundle + else None ) bundle_has_message_plan = ( isinstance(turn_bundle, dict) and bool(turn_bundle.get("has_message_plan")) and isinstance(turn_bundle.get("message_plan"), dict) ) - early_turn_decision = ( - turn_bundle.get("turn_decision") - if bundle_has_useful_turn_decision - else await self._extract_turn_decision_with_llm( - message=message, - user_id=user_id, - ) - ) reset_override = await self._try_handle_immediate_context_reset( message=message, user_id=user_id, @@ -286,9 +284,23 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin): return deterministic_rental_management + synthesized_message_plan = ( + self._synthesize_message_plan_from_turn_decision( + message=message, + turn_decision=early_turn_decision, + ) + if not bundle_has_message_plan + and self._can_synthesize_message_plan_from_turn_decision( + message=message, + turn_decision=early_turn_decision, + ) + else None + ) message_plan = ( turn_bundle.get("message_plan") if bundle_has_message_plan + else synthesized_message_plan + if isinstance(synthesized_message_plan, dict) else await self._extract_message_plan_with_llm( message=message, user_id=user_id, @@ -2280,6 +2292,37 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin): entities = turn_decision.get("entities") return self._has_useful_extraction(self._extracted_entities_from_turn_decision(turn_decision)) if isinstance(entities, dict) else False + def _should_attempt_turn_bundle(self, message: str, early_turn_decision: dict | None) -> bool: + # O bundle ficou caro e instavel nas amostras atuais. + # Mantemos o caminho desabilitado por padrao e deixamos opt-in + # para cenarios/testes onde ainda queremos exercita-lo. + return False + + def _can_synthesize_message_plan_from_turn_decision(self, message: str, turn_decision: dict | None) -> bool: + if not str(message or "").strip(): + return False + normalized_tool_name = self.normalizer.normalize_tool_name((turn_decision or {}).get("tool_name")) + return normalized_tool_name in {"consultar_estoque", "avaliar_veiculo_troca"} + + def _synthesize_message_plan_from_turn_decision(self, message: str, turn_decision: dict | None) -> dict: + domain = self._domain_from_turn_decision(turn_decision) + normalized_tool_name = self.normalizer.normalize_tool_name((turn_decision or {}).get("tool_name")) + if domain == "general" and normalized_tool_name in {"consultar_estoque", "avaliar_veiculo_troca"}: + domain = "sales" + extracted_entities = self._merge_extracted_entities( + self._empty_extraction_payload(), + self._extracted_entities_from_turn_decision(turn_decision), + ) + return { + "orders": [ + { + "domain": domain, + "message": str(message or "").strip(), + "entities": extracted_entities, + } + ] + } + def _build_reusable_router_result_payload(self, llm_result: dict | None, source: str) -> dict | None: if not isinstance(llm_result, dict): return None @@ -2356,6 +2399,24 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin): review_fields[field] = value extracted["review_fields"] = review_fields + if normalized_tool_name == "consultar_estoque" and isinstance(raw_tool_arguments, dict): + normalized_arguments = self.normalizer.normalize_tool_arguments( + "consultar_estoque", + raw_tool_arguments, + ) + if normalized_arguments: + generic_memory = extracted.get("generic_memory") + if not isinstance(generic_memory, dict): + generic_memory = {} + budget = normalized_arguments.get("preco_max") + if budget not in (None, "", [], {}): + generic_memory["orcamento_max"] = int(round(float(budget))) + category = str(normalized_arguments.get("categoria") or "").strip().lower() + if category: + existing_profiles = normalize_vehicle_profile(generic_memory.get("perfil_veiculo")) + generic_memory["perfil_veiculo"] = normalize_vehicle_profile([*existing_profiles, category]) + extracted["generic_memory"] = generic_memory + return extracted def _merge_extracted_entities(self, base: dict | None, override: dict | None) -> dict: diff --git a/tests/test_turn_decision_contract.py b/tests/test_turn_decision_contract.py index a1cd76a..2c24d82 100644 --- a/tests/test_turn_decision_contract.py +++ b/tests/test_turn_decision_contract.py @@ -26,9 +26,14 @@ class FakeLLM: def __init__(self, responses): self.responses = list(responses) self.calls = 0 + self.bundle_model_names = ["gemini-2.5-pro"] + self.preferred_models_history = [] + self.generation_config_history = [] - async def generate_response(self, message: str, tools): + async def generate_response(self, message: str, tools, preferred_models=None, generation_config=None): self.calls += 1 + self.preferred_models_history.append(list(preferred_models or [])) + self.generation_config_history.append(dict(generation_config or {})) if self.responses: return self.responses.pop(0) return {"response": "", "tool_call": None} @@ -168,7 +173,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): "action": "ask_missing_fields", "entities": { "generic_memory": {}, - "review_fields": {"placa": "abc1234", "data_hora": "10/03/2026 às 09:00"}, + "review_fields": {"placa": "abc1234", "data_hora": "10/03/2026 09:00"}, "review_management_fields": {}, "order_fields": {}, "cancel_order_fields": {} @@ -185,14 +190,14 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): ) planner = MessagePlanner(llm=llm, normalizer=EntityNormalizer()) - decision = await planner.extract_turn_decision("Quero agendar revisão amanhã às 09:00", user_id=7) + decision = await planner.extract_turn_decision("Quero agendar revisao amanha as 09:00", user_id=7) self.assertEqual(llm.calls, 2) self.assertEqual(decision["intent"], "review_schedule") self.assertEqual(decision["domain"], "review") self.assertEqual(decision["action"], "ask_missing_fields") self.assertEqual(decision["entities"]["review_fields"]["placa"], "ABC1234") - self.assertEqual(decision["entities"]["review_fields"]["data_hora"], "10/03/2026 às 09:00") + self.assertEqual(decision["entities"]["review_fields"]["data_hora"], "10/03/2026 09:00") self.assertEqual(decision["missing_fields"], ["modelo", "ano", "km"]) async def test_extract_turn_bundle_retries_once_and_returns_structured_payload(self): @@ -216,13 +221,13 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): "missing_fields": ["modelo_veiculo"], "tool_name": null, "tool_arguments": {}, - "response_to_user": "Qual veículo você quer comprar?" + "response_to_user": "Qual veiculo voce quer comprar?" }, "message_plan": { "orders": [ { "domain": "sales", - "message": "Quero comprar um carro até 70 mil", + "message": "Quero comprar um carro ate 70 mil", "entities": { "generic_memory": {"orcamento_max": 70000}, "review_fields": {}, @@ -242,7 +247,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): ) planner = MessagePlanner(llm=llm, normalizer=EntityNormalizer()) - bundle = await planner.extract_turn_bundle("Quero comprar um carro até 70 mil", user_id=7) + bundle = await planner.extract_turn_bundle("Quero comprar um carro ate 70 mil", user_id=7) self.assertEqual(llm.calls, 2) self.assertTrue(bundle["has_turn_decision"]) @@ -251,7 +256,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(bundle["turn_decision"]["domain"], "sales") self.assertEqual(bundle["turn_decision"]["entities"]["generic_memory"]["orcamento_max"], 70000) self.assertEqual(bundle["message_plan"]["orders"][0]["domain"], "sales") - self.assertEqual(bundle["message_plan"]["orders"][0]["message"], "Quero comprar um carro até 70 mil") + self.assertEqual(bundle["message_plan"]["orders"][0]["message"], "Quero comprar um carro ate 70 mil") async def test_extract_turn_bundle_returns_partial_payload_without_retry_when_first_response_is_useful(self): llm = FakeLLM( [ @@ -310,6 +315,89 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(payload["domain"], "review") self.assertEqual(payload["action"], "answer_user") + def test_parse_json_object_unwraps_json_string_payload(self): + normalizer = EntityNormalizer() + + payload = normalizer.parse_json_object( + '"{\"intent\": \"review_schedule\", \"domain\": \"review\", \"action\": \"answer_user\"}"' + ) + + self.assertEqual(payload["intent"], "review_schedule") + self.assertEqual(payload["domain"], "review") + self.assertEqual(payload["action"], "answer_user") + + def test_parse_json_object_unwraps_single_item_list_payload(self): + normalizer = EntityNormalizer() + + payload = normalizer.parse_json_object( + '[{"intent": "review_schedule", "domain": "review", "action": "answer_user"}]' + ) + + self.assertEqual(payload["intent"], "review_schedule") + self.assertEqual(payload["domain"], "review") + self.assertEqual(payload["action"], "answer_user") + + async def test_extract_turn_bundle_prefers_configured_bundle_model_on_first_attempt(self): + llm = FakeLLM( + [ + { + "response": """ + { + "turn_decision": { + "intent": "inventory_search", + "domain": "sales", + "action": "call_tool", + "entities": { + "generic_memory": {"orcamento_max": 80000}, + "review_fields": {}, + "review_management_fields": {}, + "order_fields": {}, + "cancel_order_fields": {} + }, + "missing_fields": [], + "selection_index": null, + "tool_name": "consultar_estoque", + "tool_arguments": {"preco_max": 80000, "limite": 5}, + "response_to_user": null + }, + "message_plan": { + "orders": [ + { + "domain": "sales", + "message": "Quero ver carros ate 80 mil", + "entities": { + "generic_memory": {"orcamento_max": 80000}, + "review_fields": {}, + "review_management_fields": {}, + "order_fields": {}, + "cancel_order_fields": {}, + "intents": {} + } + } + ] + } + } + """, + "tool_call": None, + }, + ] + ) + planner = MessagePlanner(llm=llm, normalizer=EntityNormalizer()) + + bundle = await planner.extract_turn_bundle("Quero ver carros ate 80 mil", user_id=7) + + self.assertTrue(bundle["has_turn_decision"]) + self.assertTrue(bundle["has_message_plan"]) + self.assertEqual(llm.preferred_models_history[0], ["gemini-2.5-pro"]) + self.assertEqual( + llm.generation_config_history[0], + { + "candidate_count": 1, + "temperature": 0, + "max_output_tokens": 768, + }, + ) + def test_coerce_turn_decision_maps_top_level_aliases_and_embedded_intents(self): normalizer = EntityNormalizer() @@ -805,6 +893,54 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(extracted["intents"], {}) self.assertEqual(extracted["order_fields"]["vehicle_id"], 1) + def test_coerce_turn_decision_merges_inventory_tool_arguments_from_entities(self): + normalizer = EntityNormalizer() + + decision = normalizer.coerce_turn_decision( + { + "intent": "inventory_search", + "domain": "sales", + "action": "call_tool", + "entities": { + "generic_memory": {"orcamento_max": 80000, "perfil_veiculo": ["suv"]}, + "review_fields": {}, + "review_management_fields": {}, + "order_fields": {}, + "cancel_order_fields": {}, + }, + "tool_name": "consultar_estoque", + "tool_arguments": {}, + } + ) + + self.assertEqual(decision["tool_arguments"]["preco_max"], 80000.0) + self.assertEqual(decision["tool_arguments"]["categoria"], "suv") + self.assertEqual(decision["tool_arguments"]["limite"], 5) + + def test_turn_decision_inventory_tool_arguments_populate_generic_memory(self): + service = OrquestradorService.__new__(OrquestradorService) + service.normalizer = EntityNormalizer() + + extracted = service._extracted_entities_from_turn_decision( + { + "intent": "inventory_search", + "domain": "sales", + "action": "call_tool", + "entities": { + "generic_memory": {}, + "review_fields": {}, + "review_management_fields": {}, + "order_fields": {}, + "cancel_order_fields": {}, + }, + "tool_name": "consultar_estoque", + "tool_arguments": {"preco_max": 80000, "categoria": "suv"}, + } + ) + + self.assertEqual(extracted["generic_memory"]["orcamento_max"], 80000) + self.assertEqual(extracted["generic_memory"]["perfil_veiculo"], ["suv"]) + def test_turn_decision_entity_merge_preserves_generic_memory_from_previous_extraction(self): service = OrquestradorService.__new__(OrquestradorService) service.normalizer = EntityNormalizer() @@ -5306,9 +5442,89 @@ class OrquestradorLatencyOptimizationTests(unittest.IsolatedAsyncioTestCase): response = await service.handle_message("Quero avaliar meu carro para troca: Onix 2020, 45000 km", user_id=1) - self.assertEqual(len(planner_calls), 1) + self.assertEqual(len(planner_calls), 0) self.assertEqual(response, "Estimativa de troca concluida.") + async def test_handle_message_executes_inventory_tool_from_bundle_without_entity_extraction_or_router(self): + service = self._build_service() + tool_calls = [] + + async def fake_extract_turn_bundle(message: str, user_id: int | None): + return { + "turn_decision": { + "intent": "inventory_search", + "domain": "sales", + "action": "call_tool", + "entities": service.normalizer.empty_extraction_payload(), + "missing_fields": [], + "selection_index": None, + "tool_name": "consultar_estoque", + "tool_arguments": {"preco_max": 80000.0, "categoria": "suv", "limite": 5}, + "response_to_user": None, + }, + "message_plan": { + "orders": [ + { + "domain": "sales", + "message": message, + "entities": service.normalizer.empty_extraction_payload(), + } + ] + }, + "has_turn_decision": True, + "has_message_plan": True, + } + + async def fake_extract_turn_decision(message: str, user_id: int | None): + return { + "intent": "inventory_search", + "domain": "sales", + "action": "call_tool", + "entities": service.normalizer.empty_extraction_payload(), + "missing_fields": [], + "selection_index": None, + "tool_name": "consultar_estoque", + "tool_arguments": {"preco_max": 80000.0, "categoria": "suv", "limite": 5}, + "response_to_user": None, + } + + async def should_not_run_message_plan(message: str, user_id: int | None): + raise AssertionError("nao deveria consultar message_plan legado quando o bundle ja trouxe plano util") + + async def should_not_run_entities(message: str, user_id: int | None): + raise AssertionError("extracao dedicada nao deveria rodar quando a decisao de estoque ja trouxe tool_arguments") + + async def should_not_run_router(**kwargs): + raise AssertionError("nao deveria consultar o router quando a decisao estruturada ja trouxe consultar_estoque") + + async def fake_execute_tool_with_trace(tool_name, arguments, user_id=None): + tool_calls.append((tool_name, arguments, user_id)) + return [ + {"id": 1, "modelo": "Toyota Corolla 2020", "categoria": "suv", "preco": 39809.0}, + ] + + async def fake_maybe_build_stock_suggestion_response(**kwargs): + return "Estoque planejado sem router." + + service._extract_turn_bundle_with_llm = fake_extract_turn_bundle + service._should_attempt_turn_bundle = lambda **kwargs: True + service._extract_turn_decision_with_llm = fake_extract_turn_decision + service._extract_message_plan_with_llm = should_not_run_message_plan + service._extract_entities_with_llm = should_not_run_entities + service._call_llm_with_trace = should_not_run_router + service._normalize_tool_invocation = lambda tool_name, arguments, user_id: (tool_name, arguments) + service._execute_tool_with_trace = fake_execute_tool_with_trace + service._maybe_build_stock_suggestion_response = fake_maybe_build_stock_suggestion_response + service._capture_successful_tool_side_effects = lambda **kwargs: None + service._capture_review_confirmation_suggestion = lambda **kwargs: None + service._http_exception_detail = lambda exc: str(exc) + service._try_execute_business_tool_from_turn_decision = OrquestradorService._try_execute_business_tool_from_turn_decision.__get__(service, OrquestradorService) + + response = await service.handle_message("Quero ver carros ate 80000 reais", user_id=1) + + self.assertEqual(tool_calls, [("consultar_estoque", {"preco_max": 80000.0, "categoria": "suv", "limite": 5}, 1)]) + self.assertEqual(response, "Estoque planejado sem router.") + async def test_handle_message_runs_entity_extraction_when_turn_decision_entities_are_empty(self): service = self._build_service() planner_calls = [] @@ -5429,7 +5645,28 @@ class OrquestradorLatencyOptimizationTests(unittest.IsolatedAsyncioTestCase): async def fake_maybe_build_stock_suggestion_response(**kwargs): return "Estoque reutilizado do primeiro router." + async def fake_extract_turn_decision(message: str, user_id: int | None): + return { + "intent": "general", + "domain": "general", + "action": "answer_user", + "entities": { + "generic_memory": {"orcamento_max": 80000}, + "review_fields": {}, + "review_management_fields": {}, + "order_fields": {}, + "cancel_order_fields": {}, + }, + "missing_fields": [], + "selection_index": None, + "tool_name": None, + "tool_arguments": {}, + "response_to_user": None, + } + service._extract_turn_bundle_with_llm = fake_extract_turn_bundle + service._should_attempt_turn_bundle = lambda **kwargs: True + service._extract_turn_decision_with_llm = fake_extract_turn_decision service._try_execute_orchestration_control_tool = fake_try_execute_orchestration_control_tool service._call_llm_with_trace = should_not_run_router service._execute_tool_with_trace = fake_execute_tool_with_trace @@ -5483,8 +5720,24 @@ class OrquestradorLatencyOptimizationTests(unittest.IsolatedAsyncioTestCase): "has_message_plan": True, } - async def should_not_run_turn_decision(message: str, user_id: int | None): - raise AssertionError("nao deveria consultar turn_decision legado quando o bundle estiver completo") + async def fake_extract_turn_decision(message: str, user_id: int | None): + return { + "intent": "order_create", + "domain": "sales", + "action": "ask_missing_fields", + "entities": { + "generic_memory": {"orcamento_max": 70000}, + "review_fields": {}, + "review_management_fields": {}, + "order_fields": {}, + "cancel_order_fields": {}, + }, + "missing_fields": ["modelo_veiculo"], + "selection_index": None, + "tool_name": None, + "tool_arguments": {}, + "response_to_user": None, + } async def should_not_run_message_plan(message: str, user_id: int | None): raise AssertionError("nao deveria consultar message_plan legado quando o bundle estiver completo") @@ -5493,7 +5746,8 @@ class OrquestradorLatencyOptimizationTests(unittest.IsolatedAsyncioTestCase): return "Fluxo de venda continuado." service._extract_turn_bundle_with_llm = fake_extract_turn_bundle - service._extract_turn_decision_with_llm = should_not_run_turn_decision + service._should_attempt_turn_bundle = lambda **kwargs: True + service._extract_turn_decision_with_llm = fake_extract_turn_decision service._extract_message_plan_with_llm = should_not_run_message_plan service._try_collect_and_create_order = fake_try_collect_and_create_order @@ -5531,9 +5785,25 @@ class OrquestradorLatencyOptimizationTests(unittest.IsolatedAsyncioTestCase): "has_message_plan": False, } - async def should_not_run_turn_decision(message: str, user_id: int | None): + async def fake_extract_turn_decision(message: str, user_id: int | None): turn_decision_calls.append((message, user_id)) - raise AssertionError("nao deveria consultar turn_decision legado quando o bundle ja trouxe decisao util") + return { + "intent": "order_create", + "domain": "sales", + "action": "ask_missing_fields", + "entities": { + "generic_memory": {"orcamento_max": 70000}, + "review_fields": {}, + "review_management_fields": {}, + "order_fields": {}, + "cancel_order_fields": {}, + }, + "missing_fields": ["modelo_veiculo"], + "selection_index": None, + "tool_name": None, + "tool_arguments": {}, + "response_to_user": None, + } async def fake_extract_message_plan(message: str, user_id: int | None): message_plan_calls.append((message, user_id)) @@ -5551,13 +5821,14 @@ class OrquestradorLatencyOptimizationTests(unittest.IsolatedAsyncioTestCase): return "Fluxo de venda continuado." service._extract_turn_bundle_with_llm = fake_extract_turn_bundle - service._extract_turn_decision_with_llm = should_not_run_turn_decision + service._should_attempt_turn_bundle = lambda **kwargs: True + service._extract_turn_decision_with_llm = fake_extract_turn_decision service._extract_message_plan_with_llm = fake_extract_message_plan service._try_collect_and_create_order = fake_try_collect_and_create_order response = await service.handle_message("quero comprar um carro ate 70 mil", user_id=1) - self.assertEqual(len(turn_decision_calls), 0) + self.assertEqual(len(turn_decision_calls), 1) self.assertEqual(len(message_plan_calls), 1) self.assertEqual(response, "Fluxo de venda continuado.") @@ -5610,6 +5881,7 @@ class OrquestradorLatencyOptimizationTests(unittest.IsolatedAsyncioTestCase): return "Fluxo de venda continuado." service._extract_turn_bundle_with_llm = fake_extract_turn_bundle + service._should_attempt_turn_bundle = lambda **kwargs: True service._extract_turn_decision_with_llm = fake_extract_turn_decision service._extract_message_plan_with_llm = should_not_run_message_plan service._try_collect_and_create_order = fake_try_collect_and_create_order @@ -5671,6 +5943,7 @@ class OrquestradorLatencyOptimizationTests(unittest.IsolatedAsyncioTestCase): return "Fluxo de venda continuado." service._extract_turn_bundle_with_llm = fake_extract_turn_bundle + service._should_attempt_turn_bundle = lambda **kwargs: True service._extract_turn_decision_with_llm = fake_extract_turn_decision service._extract_message_plan_with_llm = fake_extract_message_plan service._try_collect_and_create_order = fake_try_collect_and_create_order @@ -5682,6 +5955,120 @@ class OrquestradorLatencyOptimizationTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(len(message_plan_calls), 1) self.assertEqual(response, "Fluxo de venda continuado.") + async def test_handle_message_skips_legacy_message_plan_when_turn_decision_already_resolves_inventory_tool(self): + service = self._build_service() + tool_calls = [] + message_plan_calls = [] + + async def fake_extract_turn_bundle(message: str, user_id: int | None): + return { + "turn_decision": service.normalizer.empty_turn_decision(), + "message_plan": service.normalizer.empty_message_plan(message), + "has_turn_decision": False, + "has_message_plan": False, + } + + async def fake_extract_turn_decision(message: str, user_id: int | None): + return { + "intent": "inventory_search", + "domain": "sales", + "action": "call_tool", + "entities": service.normalizer.empty_extraction_payload(), + "missing_fields": [], + "selection_index": None, + "tool_name": "consultar_estoque", + "tool_arguments": {"preco_max": 80000.0, "categoria": "suv", "limite": 5}, + "response_to_user": None, + } + + async def should_not_run_message_plan(message: str, user_id: int | None): + message_plan_calls.append((message, user_id)) + raise AssertionError("nao deveria consultar message_plan legado quando a turn_decision ja resolve estoque") + + async def should_not_run_entities(message: str, user_id: int | None): + raise AssertionError("extracao dedicada nao deveria rodar quando a turn_decision de estoque ja trouxe filtros") + + async def should_not_run_router(**kwargs): + raise AssertionError("nao deveria consultar o router quando a turn_decision de estoque ja trouxe tool pronta") + + async def fake_execute_tool_with_trace(tool_name, arguments, user_id=None): + tool_calls.append((tool_name, arguments, user_id)) + return [{"id": 1, "modelo": "Toyota Corolla 2020", "categoria": "suv", "preco": 39809.0}] + + async def fake_maybe_build_stock_suggestion_response(**kwargs): + return "Estoque resolvido sem message_plan legado." + + service._extract_turn_bundle_with_llm = fake_extract_turn_bundle + service._should_attempt_turn_bundle = lambda **kwargs: True + service._extract_turn_decision_with_llm = fake_extract_turn_decision + service._extract_message_plan_with_llm = should_not_run_message_plan + service._extract_entities_with_llm = should_not_run_entities + service._call_llm_with_trace = should_not_run_router + service._normalize_tool_invocation = lambda tool_name, arguments, user_id: (tool_name, arguments) + service._execute_tool_with_trace = fake_execute_tool_with_trace + service._maybe_build_stock_suggestion_response = fake_maybe_build_stock_suggestion_response + service._capture_successful_tool_side_effects = lambda **kwargs: None + service._capture_review_confirmation_suggestion = lambda **kwargs: None + service._http_exception_detail = lambda exc: str(exc) + service._try_execute_business_tool_from_turn_decision = OrquestradorService._try_execute_business_tool_from_turn_decision.__get__(service, OrquestradorService) + + response = await service.handle_message("Quero ver carros ate 80000 reais", user_id=1) + + self.assertEqual(len(message_plan_calls), 0) + self.assertEqual(tool_calls, [("consultar_estoque", {"preco_max": 80000.0, "categoria": "suv", "limite": 5}, 1)]) + self.assertEqual(response, "Estoque resolvido sem message_plan legado.") + + async def test_handle_message_skips_legacy_message_plan_when_turn_decision_already_resolves_trade_in(self): + service = self._build_service() + message_plan_calls = [] + + async def fake_extract_turn_bundle(message: str, user_id: int | None): + return { + "turn_decision": service.normalizer.empty_turn_decision(), + "message_plan": service.normalizer.empty_message_plan(message), + "has_turn_decision": False, + "has_message_plan": False, + } + + async def fake_extract_turn_decision(message: str, user_id: int | None): + return { + "intent": "general", + "domain": "sales", + "action": "call_tool", + "entities": service.normalizer.empty_extraction_payload(), + "missing_fields": [], + "selection_index": None, + "tool_name": "avaliar_veiculo_troca", + "tool_arguments": {"modelo": "Onix", "ano": 2020, "km": 45000}, + "response_to_user": None, + } + + async def should_not_run_message_plan(message: str, user_id: int | None): + message_plan_calls.append((message, user_id)) + raise AssertionError("nao deveria consultar message_plan legado quando a turn_decision ja resolve troca") + + async def should_not_run_entities(message: str, user_id: int | None): + raise AssertionError("extracao dedicada nao deveria rodar quando a turn_decision de troca ja trouxe tool_arguments") + + async def fake_try_handle_trade_in_evaluation(**kwargs): + extracted_entities = kwargs.get("extracted_entities") or {} + review_fields = extracted_entities.get("review_fields") or {} + self.assertEqual(review_fields.get("modelo"), "Onix") + self.assertEqual(review_fields.get("ano"), 2020) + self.assertEqual(review_fields.get("km"), 45000) + return "Estimativa de troca concluida sem message_plan legado." + + service._extract_turn_bundle_with_llm = fake_extract_turn_bundle + service._should_attempt_turn_bundle = lambda **kwargs: True + service._extract_turn_decision_with_llm = fake_extract_turn_decision + service._extract_message_plan_with_llm = should_not_run_message_plan + service._extract_entities_with_llm = should_not_run_entities + service._try_handle_trade_in_evaluation = fake_try_handle_trade_in_evaluation + + response = await service.handle_message("Quero avaliar meu carro para troca: Onix 2020, 45000 km", user_id=1) + + self.assertEqual(len(message_plan_calls), 0) + self.assertEqual(response, "Estimativa de troca concluida sem message_plan legado.") class OrquestradorEmailCaptureTests(unittest.IsolatedAsyncioTestCase): @@ -5801,3 +6188,5 @@ class OrquestradorEmailCaptureTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(state.get_entry("pending_email_capture_requests", 7)) if __name__ == "__main__": unittest.main() + +