🐛 fix(orchestration): priorizar fluxo de compra sobre resposta livre do modelo

- evitar que respostas answer_user ou ask_missing_fields do modelo interrompam compras ja caracterizadas
- manter o order_flow como caminho deterministico quando cpf, orcamento ou perfil ja permitem avancar
- preservar a arquitetura com o modelo decidindo o turno e o backend apenas coordenando a continuidade
- cobrir com teste de regressao o caso reproduzido no servidor para compra com orcamento e cpf
main
parent 6fe92a0ae1
commit 21661e8306

@ -203,9 +203,13 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
decision_action = str(turn_decision.get("action") or "") decision_action = str(turn_decision.get("action") or "")
decision_response = str(turn_decision.get("response_to_user") or "").strip() decision_response = str(turn_decision.get("response_to_user") or "").strip()
if decision_action == "ask_missing_fields" and decision_response: should_prioritize_order_flow = self._should_prioritize_order_flow(
turn_decision=turn_decision,
extracted_entities=extracted_entities,
)
if decision_action == "ask_missing_fields" and decision_response and not should_prioritize_order_flow:
return await finish(decision_response, queue_notice=queue_notice) return await finish(decision_response, queue_notice=queue_notice)
if decision_action == "answer_user" and decision_response: if decision_action == "answer_user" and decision_response and not should_prioritize_order_flow:
return await finish(decision_response, queue_notice=queue_notice) return await finish(decision_response, queue_notice=queue_notice)
planned_tool_response = await self._try_execute_business_tool_from_turn_decision( planned_tool_response = await self._try_execute_business_tool_from_turn_decision(
@ -909,6 +913,33 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
return domain return domain
return "general" return "general"
def _should_prioritize_order_flow(
self,
turn_decision: dict | None,
extracted_entities: dict | None,
) -> bool:
decision = turn_decision or {}
if str(decision.get("intent") or "").strip().lower() != "order_create":
return False
entities = extracted_entities if isinstance(extracted_entities, dict) else {}
generic_memory = entities.get("generic_memory")
order_fields = entities.get("order_fields")
if not isinstance(generic_memory, dict):
generic_memory = {}
if not isinstance(order_fields, dict):
order_fields = {}
return any(
(
order_fields.get("vehicle_id"),
order_fields.get("cpf"),
generic_memory.get("cpf"),
generic_memory.get("orcamento_max"),
generic_memory.get("perfil_veiculo"),
)
)
def _parse_json_object(self, text: str): def _parse_json_object(self, text: str):
return self.normalizer.parse_json_object(text) return self.normalizer.parse_json_object(text)

@ -43,6 +43,11 @@ class FakeState:
return None return None
return self.entries.get(bucket, {}).pop(user_id, None) return self.entries.get(bucket, {}).pop(user_id, None)
def get_user_context(self, user_id: int | None):
if user_id is None:
return None
return self.contexts.get(user_id)
class FakeToolExecutor: class FakeToolExecutor:
def __init__(self, result=None): def __init__(self, result=None):
@ -427,6 +432,141 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(str(decision.get("action") or ""), "answer_user") self.assertEqual(str(decision.get("action") or ""), "answer_user")
self.assertEqual(str(decision.get("response_to_user") or "").strip(), "Resposta direta do contrato.") self.assertEqual(str(decision.get("response_to_user") or "").strip(), "Resposta direta do contrato.")
async def test_handle_message_prioritizes_order_flow_over_model_answer_for_purchase_intent(self):
state = FakeState(
contexts={
1: {
"active_domain": "general",
"generic_memory": {},
"shared_memory": {},
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
}
}
)
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
service._empty_extraction_payload = service.normalizer.empty_extraction_payload
service._log_turn_event = lambda *args, **kwargs: None
service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response
async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None):
return base_response
service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order
service._upsert_user_context = lambda user_id: None
async def fake_extract_turn_decision(message: str, user_id: int | None):
return {
"intent": "order_create",
"domain": "sales",
"action": "answer_user",
"entities": {
"generic_memory": {"cpf": "12345678909", "orcamento_max": 70000},
"review_fields": {},
"review_management_fields": {},
"order_fields": {},
"cancel_order_fields": {},
},
"missing_fields": [],
"selection_index": None,
"tool_name": None,
"tool_arguments": {},
"response_to_user": "Certo! Para te ajudar a encontrar o carro ideal dentro do seu orcamento.",
}
service._extract_turn_decision_with_llm = fake_extract_turn_decision
async def fake_try_resolve_pending_order_selection(**kwargs):
return None
service._try_resolve_pending_order_selection = fake_try_resolve_pending_order_selection
async def fake_try_continue_queued_order(**kwargs):
return None
service._try_continue_queued_order = fake_try_continue_queued_order
async def fake_extract_message_plan(message: str, user_id: int | None):
return {
"orders": [
{
"domain": "sales",
"message": message,
"entities": service.normalizer.empty_extraction_payload(),
}
]
}
service._extract_message_plan_with_llm = fake_extract_message_plan
service._prepare_message_for_single_order = lambda message, user_id, routing_plan=None: (message, None, None)
service._resolve_entities_for_message_plan = lambda message_plan, routed_message: service.normalizer.empty_extraction_payload()
async def fake_extract_entities(message: str, user_id: int | None):
return {
"generic_memory": {"cpf": "12345678909", "orcamento_max": 70000},
"review_fields": {},
"review_management_fields": {},
"order_fields": {},
"cancel_order_fields": {},
"intents": {},
}
service._extract_entities_with_llm = fake_extract_entities
async def fake_extract_missing_sales_search_context_with_llm(**kwargs):
return {}
service._extract_missing_sales_search_context_with_llm = fake_extract_missing_sales_search_context_with_llm
service._domain_from_intents = lambda intents: "general"
service._handle_context_switch = lambda **kwargs: None
service._update_active_domain = lambda **kwargs: None
async def fake_try_execute_orchestration_control_tool(**kwargs):
return None
service._try_execute_orchestration_control_tool = fake_try_execute_orchestration_control_tool
async def fake_try_execute_business_tool_from_turn_decision(**kwargs):
return None
service._try_execute_business_tool_from_turn_decision = fake_try_execute_business_tool_from_turn_decision
async def fake_try_handle_review_management(**kwargs):
return None
service._try_handle_review_management = fake_try_handle_review_management
async def fake_try_confirm_pending_review(**kwargs):
return None
service._try_confirm_pending_review = fake_try_confirm_pending_review
async def fake_try_collect_and_schedule_review(**kwargs):
return None
service._try_collect_and_schedule_review = fake_try_collect_and_schedule_review
async def fake_try_collect_and_cancel_order(**kwargs):
return None
service._try_collect_and_cancel_order = fake_try_collect_and_cancel_order
async def fake_try_collect_and_create_order(**kwargs):
return "Encontrei 2 veiculo(s):\n1. Hyundai HB20 2022"
service._try_collect_and_create_order = fake_try_collect_and_create_order
response = await service.handle_message(
"Quero comprar um carro de 70 mil, meu CPF e 12345678909",
user_id=1,
)
self.assertIn("Encontrei 2 veiculo(s):", response)
async def test_pending_order_selection_prefers_turn_decision_domain(self): async def test_pending_order_selection_prefers_turn_decision_domain(self):
state = FakeState( state = FakeState(
contexts={ contexts={

Loading…
Cancel
Save