You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
orquestrador/app/services/flows/order_flow.py

212 lines
7.8 KiB
Python

import re
from datetime import datetime, timedelta
from fastapi import HTTPException
from app.services.orchestration.orchestrator_config import (
CANCEL_ORDER_REQUIRED_FIELDS,
ORDER_REQUIRED_FIELDS,
PENDING_CANCEL_ORDER_DRAFT_TTL_MINUTES,
PENDING_ORDER_DRAFT_TTL_MINUTES,
)
class OrderFlowMixin:
def _is_valid_cpf(self, cpf: str) -> bool:
digits = re.sub(r"\D", "", cpf or "")
if len(digits) != 11:
return False
if digits == digits[0] * 11:
return False
numbers = [int(d) for d in digits]
sum_first = sum(n * w for n, w in zip(numbers[:9], range(10, 1, -1)))
first_digit = 11 - (sum_first % 11)
first_digit = 0 if first_digit >= 10 else first_digit
if first_digit != numbers[9]:
return False
sum_second = sum(n * w for n, w in zip(numbers[:10], range(11, 1, -1)))
second_digit = 11 - (sum_second % 11)
second_digit = 0 if second_digit >= 10 else second_digit
return second_digit == numbers[10]
def _try_prefill_order_value_from_memory(self, user_id: int | None, payload: dict) -> None:
if user_id is None or payload.get("valor_veiculo") is not None:
return
context = self._get_user_context(user_id)
if not context:
return
memory = context.get("generic_memory", {})
budget = memory.get("orcamento_max")
if isinstance(budget, (int, float)) and budget > 0:
payload["valor_veiculo"] = float(budget)
def _render_missing_order_fields_prompt(self, missing_fields: list[str]) -> str:
labels = {
"cpf": "o CPF do cliente",
"valor_veiculo": "o valor do veiculo (R$)",
}
itens = [f"- {labels[field]}" for field in missing_fields]
return "Para realizar o pedido, preciso dos dados abaixo:\n" + "\n".join(itens)
def _render_missing_cancel_order_fields_prompt(self, missing_fields: list[str]) -> str:
labels = {
"numero_pedido": "o numero do pedido (ex.: PED-20260305123456-ABC123)",
"motivo": "o motivo do cancelamento",
}
itens = [f"- {labels[field]}" for field in missing_fields]
return "Para cancelar o pedido, preciso dos dados abaixo:\n" + "\n".join(itens)
async def _try_collect_and_create_order(
self,
message: str,
user_id: int | None,
extracted_fields: dict | None = None,
intents: dict | None = None,
) -> str | None:
if user_id is None:
return None
normalized_intents = self._normalize_intents(intents)
draft = self.state.get_entry("pending_order_drafts", user_id, expire=True)
extracted = self._normalize_order_fields(extracted_fields)
has_intent = normalized_intents.get("order_create", False)
if (
draft
and not has_intent
and (
normalized_intents.get("review_schedule", False)
or normalized_intents.get("review_list", False)
or normalized_intents.get("review_cancel", False)
or normalized_intents.get("review_reschedule", False)
or normalized_intents.get("order_cancel", False)
)
and not extracted
):
self.state.pop_entry("pending_order_drafts", user_id)
return None
if not has_intent and draft is None:
return None
if draft is None:
draft = {
"payload": {},
"expires_at": datetime.utcnow() + timedelta(minutes=PENDING_ORDER_DRAFT_TTL_MINUTES),
}
draft["payload"].update(extracted)
self._try_prefill_order_value_from_memory(user_id=user_id, payload=draft["payload"])
cpf_value = draft["payload"].get("cpf")
if cpf_value and not self._is_valid_cpf(str(cpf_value)):
draft["payload"].pop("cpf", None)
self.state.set_entry("pending_order_drafts", user_id, draft)
return "Para seguir com o pedido, preciso de um CPF valido com 11 digitos."
valor = draft["payload"].get("valor_veiculo")
if valor is not None:
try:
parsed = float(valor)
if parsed <= 0:
draft["payload"].pop("valor_veiculo", None)
else:
draft["payload"]["valor_veiculo"] = round(parsed, 2)
except (TypeError, ValueError):
draft["payload"].pop("valor_veiculo", None)
draft["expires_at"] = datetime.utcnow() + timedelta(minutes=PENDING_ORDER_DRAFT_TTL_MINUTES)
self.state.set_entry("pending_order_drafts", user_id, draft)
missing = [field for field in ORDER_REQUIRED_FIELDS if field not in draft["payload"]]
if missing:
return self._render_missing_order_fields_prompt(missing)
try:
tool_result = await self.registry.execute(
"realizar_pedido",
draft["payload"],
user_id=user_id,
)
except HTTPException as exc:
return self._http_exception_detail(exc)
finally:
self.state.pop_entry("pending_order_drafts", user_id)
return self._fallback_format_tool_result("realizar_pedido", tool_result)
async def _try_collect_and_cancel_order(
self,
message: str,
user_id: int | None,
extracted_fields: dict | None = None,
intents: dict | None = None,
) -> str | None:
if user_id is None:
return None
normalized_intents = self._normalize_intents(intents)
draft = self.state.get_entry("pending_cancel_order_drafts", user_id, expire=True)
extracted = self._normalize_cancel_order_fields(extracted_fields)
has_intent = normalized_intents.get("order_cancel", False)
if (
draft
and not has_intent
and (
normalized_intents.get("review_schedule", False)
or normalized_intents.get("review_list", False)
or normalized_intents.get("review_cancel", False)
or normalized_intents.get("review_reschedule", False)
or normalized_intents.get("order_create", False)
)
and not extracted
):
self.state.pop_entry("pending_cancel_order_drafts", user_id)
return None
if not has_intent and draft is None:
return None
if draft is None:
draft = {
"payload": {},
"expires_at": datetime.utcnow() + timedelta(minutes=PENDING_CANCEL_ORDER_DRAFT_TTL_MINUTES),
}
if (
"motivo" not in extracted
and draft["payload"].get("numero_pedido")
and not has_intent
):
free_text = (message or "").strip()
if free_text and len(free_text) >= 4:
extracted["motivo"] = free_text
draft["payload"].update(extracted)
draft["expires_at"] = datetime.utcnow() + timedelta(minutes=PENDING_CANCEL_ORDER_DRAFT_TTL_MINUTES)
self.state.set_entry("pending_cancel_order_drafts", user_id, draft)
missing = [field for field in CANCEL_ORDER_REQUIRED_FIELDS if field not in draft["payload"]]
if missing:
return self._render_missing_cancel_order_fields_prompt(missing)
try:
tool_result = await self.registry.execute(
"cancelar_pedido",
draft["payload"],
user_id=user_id,
)
except HTTPException as exc:
return self._http_exception_detail(exc)
finally:
self.state.pop_entry("pending_cancel_order_drafts", user_id)
return self._fallback_format_tool_result("cancelar_pedido", tool_result)