|
|
|
@ -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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|