🐛 fix(flows): endurecer confirmacao de opcao unica e data relativa em revisao

- exige confirmacao explicita quando a busca de estoque retorna apenas 1 veiculo antes de avancar no pedido
- preserva confirmacao pendente da unica opcao e evita criacao implicita ao receber CPF ou texto livre
- captura data relativa parcial em drafts de revisao reaproveitados e passa a pedir apenas o horario faltante
- adiciona testes para opcao unica em pedidos e para reaproveitamento com data relativa seguida de horario
main
parent 943dd57d4a
commit b2e17d0c29

@ -154,6 +154,13 @@ class OrderFlowMixin:
selected_vehicle = context.get("selected_vehicle")
return dict(selected_vehicle) if isinstance(selected_vehicle, dict) else None
def _get_pending_single_vehicle_confirmation(self, user_id: int | None) -> dict | None:
context = self._get_user_context(user_id)
if not context:
return None
pending_vehicle = context.get("pending_single_vehicle_confirmation")
return dict(pending_vehicle) if isinstance(pending_vehicle, dict) else None
def _remember_stock_results(self, user_id: int | None, stock_results: list[dict] | None) -> None:
context = self._get_user_context(user_id)
if not context:
@ -178,6 +185,7 @@ class OrderFlowMixin:
context["last_stock_results"] = sanitized
if sanitized:
context["selected_vehicle"] = None
context["pending_single_vehicle_confirmation"] = None
self._save_user_context(user_id=user_id, context=context)
def _store_selected_vehicle(self, user_id: int | None, vehicle: dict | None) -> None:
@ -187,6 +195,25 @@ class OrderFlowMixin:
if not context:
return
context["selected_vehicle"] = dict(vehicle) if isinstance(vehicle, dict) else None
context["pending_single_vehicle_confirmation"] = None
self._save_user_context(user_id=user_id, context=context)
def _store_pending_single_vehicle_confirmation(self, user_id: int | None, vehicle: dict | None) -> None:
if user_id is None:
return
context = self._get_user_context(user_id)
if not context:
return
context["pending_single_vehicle_confirmation"] = dict(vehicle) if isinstance(vehicle, dict) else None
self._save_user_context(user_id=user_id, context=context)
def _clear_pending_single_vehicle_confirmation(self, user_id: int | None) -> None:
if user_id is None:
return
context = self._get_user_context(user_id)
if not isinstance(context, dict):
return
context["pending_single_vehicle_confirmation"] = None
self._save_user_context(user_id=user_id, context=context)
def _vehicle_to_payload(self, vehicle: dict) -> dict:
@ -199,6 +226,8 @@ class OrderFlowMixin:
def _try_prefill_order_vehicle_from_context(self, user_id: int | None, payload: dict) -> None:
if user_id is None or payload.get("vehicle_id"):
return
if self._get_pending_single_vehicle_confirmation(user_id=user_id):
return
selected_vehicle = self._get_selected_vehicle(user_id=user_id)
if selected_vehicle:
payload.update(self._vehicle_to_payload(selected_vehicle))
@ -282,6 +311,7 @@ class OrderFlowMixin:
return
context["last_stock_results"] = []
context["selected_vehicle"] = None
context["pending_single_vehicle_confirmation"] = None
self._save_user_context(user_id=user_id, context=context)
def _match_vehicle_from_message_index(self, message: str, stock_results: list[dict]) -> dict | None:
@ -365,6 +395,25 @@ class OrderFlowMixin:
lines.append("Pode responder com o numero da lista ou com o modelo do veiculo.")
return "\n".join(lines)
def _render_single_vehicle_confirmation_prompt(self, vehicle: dict) -> str:
return (
"Encontrei 1 opcao para o seu pedido:\n"
f"- 1. {vehicle.get('modelo', 'N/A')} ({vehicle.get('categoria', 'N/A')}) - "
f"R$ {float(vehicle.get('preco', 0)):.2f}\n"
"Posso seguir com essa opcao? Responda com 1, sim ou com o modelo do veiculo."
)
def _message_confirms_single_vehicle(self, message: str, vehicle: dict) -> bool:
normalized_message = self._normalize_text(message).strip()
if self._is_affirmative_message(message):
return True
if normalized_message == "1":
return True
normalized_model = self._normalize_text(str(vehicle.get("modelo") or "")).strip()
if normalized_model and normalized_model in normalized_message:
return True
return False
async def _try_list_stock_for_order_selection(
self,
message: str,
@ -391,7 +440,11 @@ class OrderFlowMixin:
except HTTPException as exc:
return self._http_exception_detail(exc)
self._remember_stock_results(user_id=user_id, stock_results=tool_result if isinstance(tool_result, list) else [])
stock_results = tool_result if isinstance(tool_result, list) else []
self._remember_stock_results(user_id=user_id, stock_results=stock_results)
if len(stock_results) == 1:
self._store_pending_single_vehicle_confirmation(user_id=user_id, vehicle=stock_results[0])
return self._render_single_vehicle_confirmation_prompt(stock_results[0])
return self._fallback_format_tool_result("consultar_estoque", tool_result)
def _render_missing_cancel_order_fields_prompt(self, missing_fields: list[str]) -> str:
@ -497,6 +550,22 @@ class OrderFlowMixin:
draft["payload"].pop("modelo_veiculo", None)
draft["payload"].pop("valor_veiculo", None)
pending_single_vehicle = self._get_pending_single_vehicle_confirmation(user_id=user_id)
if pending_single_vehicle and not draft["payload"].get("vehicle_id"):
if self._message_confirms_single_vehicle(message=message, vehicle=pending_single_vehicle):
self._store_selected_vehicle(user_id=user_id, vehicle=pending_single_vehicle)
draft["payload"].update(self._vehicle_to_payload(pending_single_vehicle))
pending_single_vehicle = None
elif self._is_negative_message(message):
self._clear_pending_single_vehicle_confirmation(user_id=user_id)
draft["expires_at"] = datetime.utcnow() + timedelta(minutes=PENDING_ORDER_DRAFT_TTL_MINUTES)
self.state.set_entry("pending_order_drafts", user_id, draft)
return "Sem problema. Me diga outro modelo ou ajuste o valor para eu buscar novas opcoes."
elif not self._has_explicit_order_request(message):
draft["expires_at"] = datetime.utcnow() + timedelta(minutes=PENDING_ORDER_DRAFT_TTL_MINUTES)
self.state.set_entry("pending_order_drafts", user_id, draft)
return self._render_single_vehicle_confirmation_prompt(pending_single_vehicle)
resolved_vehicle = self._try_resolve_order_vehicle(
message=message,
user_id=user_id,

@ -121,6 +121,16 @@ class ReviewFlowMixin:
payload["data_hora"] = f"{payload['data_hora_base']} {time_text}"
payload.pop("data_hora_base", None)
def _store_review_base_date_from_message(self, message: str, payload: dict) -> None:
if not isinstance(payload, dict):
return
if payload.get("data_hora") or payload.get("data_hora_base"):
return
date_only = self._extract_review_date_only_text(message)
if not date_only:
return
payload["data_hora_base"] = date_only
def _infer_review_management_action(
self,
message: str,
@ -521,6 +531,8 @@ class ReviewFlowMixin:
self._merge_review_base_date_with_time(message=message, payload=draft["payload"])
self._supplement_review_fields_from_message(message=message, payload=draft["payload"])
self._merge_review_base_date_with_time(message=message, payload=draft["payload"])
self._store_review_base_date_from_message(message=message, payload=draft["payload"])
self._merge_review_base_date_with_time(message=message, payload=draft["payload"])
self._try_prefill_review_fields_from_memory(user_id=user_id, payload=draft["payload"])
if (
"revisao_previa_concessionaria" not in draft["payload"]
@ -537,6 +549,11 @@ class ReviewFlowMixin:
missing = [field for field in REVIEW_REQUIRED_FIELDS if field not in draft["payload"]]
if missing:
self._log_review_flow_source(source=review_flow_source or "draft", payload=draft["payload"], missing_fields=missing)
if missing == ["data_hora"] and draft["payload"].get("data_hora_base"):
return (
f"Perfeito. Tenho a data {draft['payload']['data_hora_base']}. "
"Agora me informe o horario desejado para a revisao."
)
return self._render_missing_review_fields_prompt(missing)
try:

@ -182,6 +182,14 @@ class OrderFlowHarness(OrderFlowMixin):
def _normalize_text(self, text: str) -> str:
return self.normalizer.normalize_text(text)
def _is_affirmative_message(self, text: str) -> bool:
normalized = self.normalizer.normalize_text(text).strip().rstrip(".!?,;:")
return normalized in {"sim", "pode", "ok", "confirmo", "aceito", "fechado", "pode sim", "continuar"}
def _is_negative_message(self, text: str) -> bool:
normalized = self.normalizer.normalize_text(text).strip().rstrip(".!?,;:")
return normalized in {"nao", "não", "negativo", "cancelar", "outro", "outra opcao", "outra opção"}
def _normalize_positive_number(self, value):
return self.normalizer.normalize_positive_number(value)
@ -800,6 +808,86 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("Honda Civic 2021", response)
self.assertEqual(registry.calls, [])
async def test_order_flow_single_stock_result_requires_explicit_confirmation(self):
state = FakeState(
entries={
"pending_order_drafts": {
10: {
"payload": {"cpf": "12345678909"},
"expires_at": datetime.utcnow() + timedelta(minutes=30),
}
}
},
contexts={
10: {
"generic_memory": {"cpf": "12345678909", "orcamento_max": 70000},
"shared_memory": {"orcamento_max": 70000},
"last_stock_results": [],
"selected_vehicle": None,
}
},
)
registry = FakeRegistry()
async def single_result_execute(tool_name: str, arguments: dict, user_id: int | None = None):
registry.calls.append((tool_name, arguments, user_id))
if tool_name == "consultar_estoque":
return [{"id": 7, "modelo": "Fiat Argo 2020", "categoria": "suv", "preco": 61857.0}]
if tool_name == "realizar_pedido":
return {
"numero_pedido": "PED-TESTE-123",
"status": "Ativo",
"modelo_veiculo": "Fiat Argo 2020",
"valor_veiculo": 61857.0,
}
raise AssertionError(f"Tool inesperada no teste: {tool_name}")
registry.execute = single_result_execute
flow = OrderFlowHarness(state=state, registry=registry)
async def fake_hydrate_mock_customer_from_cpf(cpf: str, user_id: int | None = None):
return {"cpf": cpf, "user_id": user_id}
with patch(
"app.services.flows.order_flow.hydrate_mock_customer_from_cpf",
new=fake_hydrate_mock_customer_from_cpf,
):
response = await flow._try_collect_and_create_order(
message="quero comprar um carro de ate 70 mil",
user_id=10,
extracted_fields={},
intents={"order_create": True},
)
self.assertIn("Encontrei 1 opcao", response)
self.assertIn("Fiat Argo 2020", response)
self.assertEqual(len(registry.calls), 1)
self.assertEqual(registry.calls[0][0], "consultar_estoque")
self.assertEqual(state.get_user_context(10)["selected_vehicle"], None)
self.assertIsNotNone(state.get_user_context(10)["pending_single_vehicle_confirmation"])
follow_up_response = await flow._try_collect_and_create_order(
message="12345678909",
user_id=10,
extracted_fields={},
intents={},
)
self.assertIn("Posso seguir com essa opcao", follow_up_response)
self.assertEqual(len(registry.calls), 1)
confirmation_response = await flow._try_collect_and_create_order(
message="sim",
user_id=10,
extracted_fields={},
intents={},
)
self.assertEqual(len(registry.calls), 2)
self.assertEqual(registry.calls[1][0], "realizar_pedido")
self.assertEqual(registry.calls[1][1]["vehicle_id"], 7)
self.assertIn("Pedido criado com sucesso.", confirmation_response)
async def test_order_flow_creates_order_with_selected_vehicle_from_list_index(self):
state = FakeState(
entries={
@ -1357,6 +1445,62 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(registry.calls[0][1]["data_hora"], f"{today_text} 14:00")
self.assertIn("REV-TESTE-123", final_response)
async def test_review_flow_reuse_confirmation_accepts_relative_date_then_time_in_follow_up(self):
state = FakeState(
entries={
"pending_review_reuse_confirmations": {
21: {
"payload": {
"placa": "ABC1263",
"modelo": "Onix",
"ano": 2024,
"km": 50000,
"revisao_previa_concessionaria": False,
},
"expires_at": datetime.utcnow() + timedelta(minutes=30),
}
}
}
)
registry = FakeRegistry()
flow = ReviewFlowHarness(state=state, registry=registry)
today_text = datetime.now().strftime("%d/%m/%Y")
first_response = await flow._try_collect_and_schedule_review(
message="sim",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "review_schedule", "domain": "review", "action": "collect_review_schedule"},
)
self.assertIn("Me informe apenas a data e hora desejada", first_response)
second_response = await flow._try_collect_and_schedule_review(
message="quero marcar para hoje",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "review_schedule", "domain": "review", "action": "collect_review_schedule"},
)
draft = state.get_entry("pending_review_drafts", 21)
self.assertIsNotNone(draft)
self.assertEqual(draft["payload"].get("data_hora_base"), today_text)
self.assertIn("Agora me informe o horario desejado", second_response)
final_response = await flow._try_collect_and_schedule_review(
message="as 14 horas",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "review_schedule", "domain": "review", "action": "collect_review_schedule"},
)
self.assertEqual(registry.calls[0][0], "agendar_revisao")
self.assertEqual(registry.calls[0][1]["data_hora"], f"{today_text} 14:00")
self.assertIn("REV-TESTE-123", final_response)
async def test_review_flow_clears_stale_pending_confirmation_when_user_starts_new_schedule(self):
state = FakeState(
entries={

Loading…
Cancel
Save