From ecb5c8960f765dedfbbf336a5a5a49a512e4811b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vitor=20Hugo=20Belorio=20Sim=C3=A3o?= Date: Thu, 26 Mar 2026 10:21:40 -0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20fix(orchestration):=20r?= =?UTF-8?q?estringir=20atalhos=20agressivos=20antes=20do=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../orchestration/orquestrador_service.py | 58 +++++++- .../orchestration/technical_normalizer.py | 5 +- tests/test_turn_decision_contract.py | 124 +++++++++++++++++- 3 files changed, 182 insertions(+), 5 deletions(-) diff --git a/app/services/orchestration/orquestrador_service.py b/app/services/orchestration/orquestrador_service.py index aebef71..6b17361 100644 --- a/app/services/orchestration/orquestrador_service.py +++ b/app/services/orchestration/orquestrador_service.py @@ -1,4 +1,4 @@ -import json +import json import logging import re from datetime import datetime, timedelta @@ -2301,8 +2301,60 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin): def _can_synthesize_message_plan_from_turn_decision(self, message: str, turn_decision: dict | None) -> bool: if not str(message or "").strip(): return False + if str((turn_decision or {}).get("action") or "").strip().lower() != "call_tool": + 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"} + if normalized_tool_name not in {"consultar_estoque", "avaliar_veiculo_troca"}: + return False + if self._has_message_plan_synthesis_conflict( + message=message, + turn_decision=turn_decision, + normalized_tool_name=normalized_tool_name, + ): + return False + if normalized_tool_name == "consultar_estoque": + return self._has_stock_listing_request(message, turn_decision=turn_decision) + return self._has_trade_in_evaluation_request(message, turn_decision=turn_decision) + + def _has_message_plan_synthesis_conflict( + self, + message: str, + turn_decision: dict | None, + normalized_tool_name: str, + ) -> bool: + normalized_message = self._normalize_text(message).strip() + if not normalized_message: + return False + + seed_order = { + "domain": "sales", + "message": str(message or "").strip(), + "entities": self._empty_extraction_payload(), + } + augmented_orders = [seed_order] + if hasattr(self, "policy") and self.policy is not None: + augmented_orders = self.policy.augment_actionable_orders_from_message( + message=message, + extracted_orders=[seed_order], + ) + actionable_domains = { + str(order.get("domain") or "general") + for order in augmented_orders + if isinstance(order, dict) + } + if len(actionable_domains & {"sales", "review", "rental"}) > 1: + return True + + if self._has_order_listing_request(message=message, turn_decision=turn_decision): + return True + + if normalized_tool_name == "consultar_estoque": + return self._has_trade_in_evaluation_request(message, turn_decision=turn_decision) + + return ( + self._has_stock_listing_request(message=message, turn_decision=turn_decision) + or self._has_explicit_order_request(message) + ) def _synthesize_message_plan_from_turn_decision(self, message: str, turn_decision: dict | None) -> dict: domain = self._domain_from_turn_decision(turn_decision) @@ -3343,3 +3395,5 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin): tool_name=tool_name, tool_result=tool_result, ) + + diff --git a/app/services/orchestration/technical_normalizer.py b/app/services/orchestration/technical_normalizer.py index d558c15..f53ee69 100644 --- a/app/services/orchestration/technical_normalizer.py +++ b/app/services/orchestration/technical_normalizer.py @@ -1,4 +1,4 @@ -import re +import re import unicodedata from datetime import datetime, timedelta @@ -92,7 +92,7 @@ def extract_budget_from_text(text: str) -> float | None: normalized = normalize_text(candidate) keyword_match = re.search( - r"(?:ate|até|de|por|orcamento|orçamento)\s+(\d[\d\.\,\s]{1,12})(?!\d)", + r"(?:ate|de|por|orcamento)\s+(\d[\d\.\,\s]{1,12})(?!\d)", normalized, flags=re.IGNORECASE, ) @@ -326,3 +326,4 @@ def normalize_order_number(value) -> str | None: if order_number and re.fullmatch(r"PED-[A-Z0-9\\-]+", order_number): return order_number return None + diff --git a/tests/test_turn_decision_contract.py b/tests/test_turn_decision_contract.py index 2c24d82..b8a55f4 100644 --- a/tests/test_turn_decision_contract.py +++ b/tests/test_turn_decision_contract.py @@ -1,4 +1,4 @@ -import os +import os import unittest from types import SimpleNamespace from unittest.mock import AsyncMock, patch @@ -19,6 +19,7 @@ from app.services.orchestration.conversation_policy import ConversationPolicy from app.services.orchestration.entity_normalizer import EntityNormalizer from app.services.orchestration.message_planner import MessagePlanner from app.services.orchestration.orquestrador_service import OrquestradorService +from app.services.orchestration.technical_normalizer import extract_budget_from_text from app.services.orchestration.tool_executor import ToolExecutor @@ -398,6 +399,49 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): }, ) + def test_extract_budget_from_text_supports_accented_and_plain_keywords(self): + self.assertEqual(extract_budget_from_text("Quero ver carros at\u00e9 80000 reais"), 80000.0) + self.assertEqual(extract_budget_from_text("Quero ver carros ate 80000 reais"), 80000.0) + self.assertEqual(extract_budget_from_text("Meu or\u00e7amento 75.000"), 75000.0) + + def test_turn_decision_message_plan_synthesis_rejects_compound_inventory_request(self): + service = OrquestradorService.__new__(OrquestradorService) + service.normalizer = EntityNormalizer() + service.policy = ConversationPolicy(service=service) + + can_synthesize = service._can_synthesize_message_plan_from_turn_decision( + "Quero ver carros ate 80000 reais e listar meus pedidos", + { + "intent": "inventory_search", + "domain": "sales", + "action": "call_tool", + "tool_name": "consultar_estoque", + "tool_arguments": {"preco_max": 80000.0, "limite": 5}, + "entities": service.normalizer.empty_extraction_payload(), + }, + ) + + self.assertFalse(can_synthesize) + + def test_turn_decision_message_plan_synthesis_rejects_trade_in_with_additional_review_request(self): + service = OrquestradorService.__new__(OrquestradorService) + service.normalizer = EntityNormalizer() + service.policy = ConversationPolicy(service=service) + + can_synthesize = service._can_synthesize_message_plan_from_turn_decision( + "Quero avaliar meu carro para troca e agendar revisao", + { + "intent": "general", + "domain": "sales", + "action": "call_tool", + "tool_name": "avaliar_veiculo_troca", + "tool_arguments": {"modelo": "Onix", "ano": 2020, "km": 45000}, + "entities": service.normalizer.empty_extraction_payload(), + }, + ) + + self.assertFalse(can_synthesize) + def test_coerce_turn_decision_maps_top_level_aliases_and_embedded_intents(self): normalizer = EntityNormalizer() @@ -6071,6 +6115,79 @@ class OrquestradorLatencyOptimizationTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(response, "Estimativa de troca concluida sem message_plan legado.") + async def test_handle_message_short_circuit_paths_remain_stable_under_repeated_hot_path_load(self): + service = self._build_service() + tool_calls = [] + trade_in_calls = [] + message_plan_calls = [] + + async def fake_extract_turn_decision(message: str, user_id: int | None): + if "troca" in message: + 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, + } + 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("message_plan legado nao deveria rodar nos hot paths repetidos") + + async def should_not_run_entities(message: str, user_id: int | None): + raise AssertionError("extracao dedicada nao deveria rodar nos hot paths repetidos") + + 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 ok." + + async def fake_try_handle_trade_in_evaluation(**kwargs): + if "troca" not in str(kwargs.get("message") or ""): + return None + trade_in_calls.append(kwargs.get("extracted_entities") or {}) + return "Troca ok." + + 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._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) + service._try_handle_trade_in_evaluation = fake_try_handle_trade_in_evaluation + + for _ in range(20): + inventory_response = await service.handle_message("Quero ver carros ate 80000 reais", user_id=1) + trade_in_response = await service.handle_message("Quero avaliar meu carro para troca: Onix 2020, 45000 km", user_id=1) + self.assertEqual(inventory_response, "Estoque ok.") + self.assertEqual(trade_in_response, "Troca ok.") + + self.assertEqual(len(message_plan_calls), 0) + self.assertEqual(len(tool_calls), 20) + self.assertEqual(len(trade_in_calls), 20) + class OrquestradorEmailCaptureTests(unittest.IsolatedAsyncioTestCase): def _build_service(self, state=None): service = OrquestradorService.__new__(OrquestradorService) @@ -6190,3 +6307,8 @@ if __name__ == "__main__": unittest.main() + + + + +