🛡️ fix(orchestration): restringir atalhos agressivos antes do merge

chore/observability-latency-markers
parent 431d783eac
commit ecb5c8960f

@ -1,4 +1,4 @@
import json import json
import logging import logging
import re import re
from datetime import datetime, timedelta 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: def _can_synthesize_message_plan_from_turn_decision(self, message: str, turn_decision: dict | None) -> bool:
if not str(message or "").strip(): if not str(message or "").strip():
return False 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")) 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: def _synthesize_message_plan_from_turn_decision(self, message: str, turn_decision: dict | None) -> dict:
domain = self._domain_from_turn_decision(turn_decision) domain = self._domain_from_turn_decision(turn_decision)
@ -3343,3 +3395,5 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
tool_name=tool_name, tool_name=tool_name,
tool_result=tool_result, tool_result=tool_result,
) )

@ -1,4 +1,4 @@
import re import re
import unicodedata import unicodedata
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -92,7 +92,7 @@ def extract_budget_from_text(text: str) -> float | None:
normalized = normalize_text(candidate) normalized = normalize_text(candidate)
keyword_match = re.search( 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, normalized,
flags=re.IGNORECASE, 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): if order_number and re.fullmatch(r"PED-[A-Z0-9\\-]+", order_number):
return order_number return order_number
return None return None

@ -1,4 +1,4 @@
import os import os
import unittest import unittest
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, patch 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.entity_normalizer import EntityNormalizer
from app.services.orchestration.message_planner import MessagePlanner from app.services.orchestration.message_planner import MessagePlanner
from app.services.orchestration.orquestrador_service import OrquestradorService 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 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): def test_coerce_turn_decision_maps_top_level_aliases_and_embedded_intents(self):
normalizer = EntityNormalizer() normalizer = EntityNormalizer()
@ -6071,6 +6115,79 @@ class OrquestradorLatencyOptimizationTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response, "Estimativa de troca concluida sem message_plan legado.") 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): class OrquestradorEmailCaptureTests(unittest.IsolatedAsyncioTestCase):
def _build_service(self, state=None): def _build_service(self, state=None):
service = OrquestradorService.__new__(OrquestradorService) service = OrquestradorService.__new__(OrquestradorService)
@ -6190,3 +6307,8 @@ if __name__ == "__main__":
unittest.main() unittest.main()

Loading…
Cancel
Save