perf(orchestration): tirar bundle do caminho critico do turno

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

@ -338,7 +338,7 @@ class EntityNormalizer:
stripped = re.sub(r"\s*```$", "", stripped) stripped = re.sub(r"\s*```$", "", stripped)
return stripped.strip() 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() normalized = str(candidate or "").strip()
if not normalized: if not normalized:
return None return None
@ -359,15 +359,32 @@ class EntityNormalizer:
for variant in variants: for variant in variants:
try: try:
return json.loads(variant) parsed = json.loads(variant)
except json.JSONDecodeError: except json.JSONDecodeError:
pass parsed = None
coerced = self._coerce_parsed_json_candidate(parsed, depth=depth)
if isinstance(coerced, dict):
return coerced
try: try:
parsed = ast.literal_eval(variant) parsed = ast.literal_eval(variant)
except (ValueError, SyntaxError): except (ValueError, SyntaxError):
continue continue
if isinstance(parsed, dict): coerced = self._coerce_parsed_json_candidate(parsed, depth=depth)
return parsed 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 return None
def coerce_turn_decision(self, payload) -> dict: def coerce_turn_decision(self, payload) -> dict:
@ -431,7 +448,12 @@ class EntityNormalizer:
normalized["tool_name"] = tool_name or None normalized["tool_name"] = tool_name or None
tool_arguments = normalized.get("tool_arguments") tool_arguments = normalized.get("tool_arguments")
if tool_name and isinstance(tool_arguments, dict): 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: else:
normalized["tool_arguments"] = tool_arguments if isinstance(tool_arguments, dict) else {} normalized["tool_arguments"] = tool_arguments if isinstance(tool_arguments, dict) else {}
@ -707,6 +729,28 @@ class EntityNormalizer:
return payload 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]: def _normalize_turn_missing_fields(self, missing_fields) -> list[str]:
if missing_fields is None: if missing_fields is None:
return [] return []

@ -230,29 +230,27 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
return deterministic_rental_bootstrap return deterministic_rental_bootstrap
# Faz uma leitura inicial do turno para ajudar a policy # Faz uma leitura inicial do turno para ajudar a policy
# com fila, troca de contexto e comandos globais. # 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, message=message,
user_id=user_id, user_id=user_id,
) )
bundle_has_useful_turn_decision = ( use_turn_bundle = self._should_attempt_turn_bundle(
isinstance(turn_bundle, dict) message=message,
and bool(turn_bundle.get("has_turn_decision")) early_turn_decision=early_turn_decision,
and isinstance(turn_bundle.get("turn_decision"), dict) )
and self._has_useful_turn_decision(turn_bundle.get("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 = ( bundle_has_message_plan = (
isinstance(turn_bundle, dict) isinstance(turn_bundle, dict)
and bool(turn_bundle.get("has_message_plan")) and bool(turn_bundle.get("has_message_plan"))
and isinstance(turn_bundle.get("message_plan"), dict) 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( reset_override = await self._try_handle_immediate_context_reset(
message=message, message=message,
user_id=user_id, user_id=user_id,
@ -286,9 +284,23 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
return deterministic_rental_management 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 = ( message_plan = (
turn_bundle.get("message_plan") turn_bundle.get("message_plan")
if bundle_has_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( else await self._extract_message_plan_with_llm(
message=message, message=message,
user_id=user_id, user_id=user_id,
@ -2280,6 +2292,37 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
entities = turn_decision.get("entities") entities = turn_decision.get("entities")
return self._has_useful_extraction(self._extracted_entities_from_turn_decision(turn_decision)) if isinstance(entities, dict) else False 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: def _build_reusable_router_result_payload(self, llm_result: dict | None, source: str) -> dict | None:
if not isinstance(llm_result, dict): if not isinstance(llm_result, dict):
return None return None
@ -2356,6 +2399,24 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
review_fields[field] = value review_fields[field] = value
extracted["review_fields"] = review_fields 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 return extracted
def _merge_extracted_entities(self, base: dict | None, override: dict | None) -> dict: def _merge_extracted_entities(self, base: dict | None, override: dict | None) -> dict:

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

Loading…
Cancel
Save