|
|
|
@ -26,9 +26,14 @@ class FakeLLM:
|
|
|
|
def __init__(self, responses):
|
|
|
|
def __init__(self, responses):
|
|
|
|
self.responses = list(responses)
|
|
|
|
self.responses = list(responses)
|
|
|
|
self.calls = 0
|
|
|
|
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.calls += 1
|
|
|
|
|
|
|
|
self.preferred_models_history.append(list(preferred_models or []))
|
|
|
|
|
|
|
|
self.generation_config_history.append(dict(generation_config or {}))
|
|
|
|
if self.responses:
|
|
|
|
if self.responses:
|
|
|
|
return self.responses.pop(0)
|
|
|
|
return self.responses.pop(0)
|
|
|
|
return {"response": "", "tool_call": None}
|
|
|
|
return {"response": "", "tool_call": None}
|
|
|
|
@ -168,7 +173,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
|
|
|
|
"action": "ask_missing_fields",
|
|
|
|
"action": "ask_missing_fields",
|
|
|
|
"entities": {
|
|
|
|
"entities": {
|
|
|
|
"generic_memory": {},
|
|
|
|
"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": {},
|
|
|
|
"review_management_fields": {},
|
|
|
|
"order_fields": {},
|
|
|
|
"order_fields": {},
|
|
|
|
"cancel_order_fields": {}
|
|
|
|
"cancel_order_fields": {}
|
|
|
|
@ -185,14 +190,14 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
|
|
|
|
)
|
|
|
|
)
|
|
|
|
planner = MessagePlanner(llm=llm, normalizer=EntityNormalizer())
|
|
|
|
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(llm.calls, 2)
|
|
|
|
self.assertEqual(decision["intent"], "review_schedule")
|
|
|
|
self.assertEqual(decision["intent"], "review_schedule")
|
|
|
|
self.assertEqual(decision["domain"], "review")
|
|
|
|
self.assertEqual(decision["domain"], "review")
|
|
|
|
self.assertEqual(decision["action"], "ask_missing_fields")
|
|
|
|
self.assertEqual(decision["action"], "ask_missing_fields")
|
|
|
|
self.assertEqual(decision["entities"]["review_fields"]["placa"], "ABC1234")
|
|
|
|
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"])
|
|
|
|
self.assertEqual(decision["missing_fields"], ["modelo", "ano", "km"])
|
|
|
|
|
|
|
|
|
|
|
|
async def test_extract_turn_bundle_retries_once_and_returns_structured_payload(self):
|
|
|
|
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"],
|
|
|
|
"missing_fields": ["modelo_veiculo"],
|
|
|
|
"tool_name": null,
|
|
|
|
"tool_name": null,
|
|
|
|
"tool_arguments": {},
|
|
|
|
"tool_arguments": {},
|
|
|
|
"response_to_user": "Qual veículo você quer comprar?"
|
|
|
|
"response_to_user": "Qual veiculo voce quer comprar?"
|
|
|
|
},
|
|
|
|
},
|
|
|
|
"message_plan": {
|
|
|
|
"message_plan": {
|
|
|
|
"orders": [
|
|
|
|
"orders": [
|
|
|
|
{
|
|
|
|
{
|
|
|
|
"domain": "sales",
|
|
|
|
"domain": "sales",
|
|
|
|
"message": "Quero comprar um carro até 70 mil",
|
|
|
|
"message": "Quero comprar um carro ate 70 mil",
|
|
|
|
"entities": {
|
|
|
|
"entities": {
|
|
|
|
"generic_memory": {"orcamento_max": 70000},
|
|
|
|
"generic_memory": {"orcamento_max": 70000},
|
|
|
|
"review_fields": {},
|
|
|
|
"review_fields": {},
|
|
|
|
@ -242,7 +247,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
|
|
|
|
)
|
|
|
|
)
|
|
|
|
planner = MessagePlanner(llm=llm, normalizer=EntityNormalizer())
|
|
|
|
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.assertEqual(llm.calls, 2)
|
|
|
|
self.assertTrue(bundle["has_turn_decision"])
|
|
|
|
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"]["domain"], "sales")
|
|
|
|
self.assertEqual(bundle["turn_decision"]["entities"]["generic_memory"]["orcamento_max"], 70000)
|
|
|
|
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]["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):
|
|
|
|
async def test_extract_turn_bundle_returns_partial_payload_without_retry_when_first_response_is_useful(self):
|
|
|
|
llm = FakeLLM(
|
|
|
|
llm = FakeLLM(
|
|
|
|
[
|
|
|
|
[
|
|
|
|
@ -310,6 +315,89 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
|
|
|
|
self.assertEqual(payload["domain"], "review")
|
|
|
|
self.assertEqual(payload["domain"], "review")
|
|
|
|
self.assertEqual(payload["action"], "answer_user")
|
|
|
|
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):
|
|
|
|
def test_coerce_turn_decision_maps_top_level_aliases_and_embedded_intents(self):
|
|
|
|
normalizer = EntityNormalizer()
|
|
|
|
normalizer = EntityNormalizer()
|
|
|
|
|
|
|
|
|
|
|
|
@ -805,6 +893,54 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
|
|
|
|
self.assertEqual(extracted["intents"], {})
|
|
|
|
self.assertEqual(extracted["intents"], {})
|
|
|
|
self.assertEqual(extracted["order_fields"]["vehicle_id"], 1)
|
|
|
|
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):
|
|
|
|
def test_turn_decision_entity_merge_preserves_generic_memory_from_previous_extraction(self):
|
|
|
|
service = OrquestradorService.__new__(OrquestradorService)
|
|
|
|
service = OrquestradorService.__new__(OrquestradorService)
|
|
|
|
service.normalizer = EntityNormalizer()
|
|
|
|
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)
|
|
|
|
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.")
|
|
|
|
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):
|
|
|
|
async def test_handle_message_runs_entity_extraction_when_turn_decision_entities_are_empty(self):
|
|
|
|
service = self._build_service()
|
|
|
|
service = self._build_service()
|
|
|
|
planner_calls = []
|
|
|
|
planner_calls = []
|
|
|
|
@ -5429,7 +5645,28 @@ class OrquestradorLatencyOptimizationTests(unittest.IsolatedAsyncioTestCase):
|
|
|
|
async def fake_maybe_build_stock_suggestion_response(**kwargs):
|
|
|
|
async def fake_maybe_build_stock_suggestion_response(**kwargs):
|
|
|
|
return "Estoque reutilizado do primeiro router."
|
|
|
|
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._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._try_execute_orchestration_control_tool = fake_try_execute_orchestration_control_tool
|
|
|
|
service._call_llm_with_trace = should_not_run_router
|
|
|
|
service._call_llm_with_trace = should_not_run_router
|
|
|
|
service._execute_tool_with_trace = fake_execute_tool_with_trace
|
|
|
|
service._execute_tool_with_trace = fake_execute_tool_with_trace
|
|
|
|
@ -5483,8 +5720,24 @@ class OrquestradorLatencyOptimizationTests(unittest.IsolatedAsyncioTestCase):
|
|
|
|
"has_message_plan": True,
|
|
|
|
"has_message_plan": True,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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):
|
|
|
|
raise AssertionError("nao deveria consultar turn_decision legado quando o bundle estiver completo")
|
|
|
|
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):
|
|
|
|
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")
|
|
|
|
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."
|
|
|
|
return "Fluxo de venda continuado."
|
|
|
|
|
|
|
|
|
|
|
|
service._extract_turn_bundle_with_llm = fake_extract_turn_bundle
|
|
|
|
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._extract_message_plan_with_llm = should_not_run_message_plan
|
|
|
|
service._try_collect_and_create_order = fake_try_collect_and_create_order
|
|
|
|
service._try_collect_and_create_order = fake_try_collect_and_create_order
|
|
|
|
|
|
|
|
|
|
|
|
@ -5531,9 +5785,25 @@ class OrquestradorLatencyOptimizationTests(unittest.IsolatedAsyncioTestCase):
|
|
|
|
"has_message_plan": False,
|
|
|
|
"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))
|
|
|
|
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):
|
|
|
|
async def fake_extract_message_plan(message: str, user_id: int | None):
|
|
|
|
message_plan_calls.append((message, user_id))
|
|
|
|
message_plan_calls.append((message, user_id))
|
|
|
|
@ -5551,13 +5821,14 @@ class OrquestradorLatencyOptimizationTests(unittest.IsolatedAsyncioTestCase):
|
|
|
|
return "Fluxo de venda continuado."
|
|
|
|
return "Fluxo de venda continuado."
|
|
|
|
|
|
|
|
|
|
|
|
service._extract_turn_bundle_with_llm = fake_extract_turn_bundle
|
|
|
|
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._extract_message_plan_with_llm = fake_extract_message_plan
|
|
|
|
service._try_collect_and_create_order = fake_try_collect_and_create_order
|
|
|
|
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)
|
|
|
|
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(len(message_plan_calls), 1)
|
|
|
|
self.assertEqual(response, "Fluxo de venda continuado.")
|
|
|
|
self.assertEqual(response, "Fluxo de venda continuado.")
|
|
|
|
|
|
|
|
|
|
|
|
@ -5610,6 +5881,7 @@ class OrquestradorLatencyOptimizationTests(unittest.IsolatedAsyncioTestCase):
|
|
|
|
return "Fluxo de venda continuado."
|
|
|
|
return "Fluxo de venda continuado."
|
|
|
|
|
|
|
|
|
|
|
|
service._extract_turn_bundle_with_llm = fake_extract_turn_bundle
|
|
|
|
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_turn_decision_with_llm = fake_extract_turn_decision
|
|
|
|
service._extract_message_plan_with_llm = should_not_run_message_plan
|
|
|
|
service._extract_message_plan_with_llm = should_not_run_message_plan
|
|
|
|
service._try_collect_and_create_order = fake_try_collect_and_create_order
|
|
|
|
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."
|
|
|
|
return "Fluxo de venda continuado."
|
|
|
|
|
|
|
|
|
|
|
|
service._extract_turn_bundle_with_llm = fake_extract_turn_bundle
|
|
|
|
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_turn_decision_with_llm = fake_extract_turn_decision
|
|
|
|
service._extract_message_plan_with_llm = fake_extract_message_plan
|
|
|
|
service._extract_message_plan_with_llm = fake_extract_message_plan
|
|
|
|
service._try_collect_and_create_order = fake_try_collect_and_create_order
|
|
|
|
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(len(message_plan_calls), 1)
|
|
|
|
self.assertEqual(response, "Fluxo de venda continuado.")
|
|
|
|
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):
|
|
|
|
class OrquestradorEmailCaptureTests(unittest.IsolatedAsyncioTestCase):
|
|
|
|
@ -5801,3 +6188,5 @@ class OrquestradorEmailCaptureTests(unittest.IsolatedAsyncioTestCase):
|
|
|
|
self.assertIsNone(state.get_entry("pending_email_capture_requests", 7))
|
|
|
|
self.assertIsNone(state.get_entry("pending_email_capture_requests", 7))
|
|
|
|
if __name__ == "__main__":
|
|
|
|
if __name__ == "__main__":
|
|
|
|
unittest.main()
|
|
|
|
unittest.main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|