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.
263 lines
9.7 KiB
Python
263 lines
9.7 KiB
Python
import re
|
|
from datetime import datetime, timedelta
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from app.db.mock_database import SessionMockLocal
|
|
from app.db.mock_models import User
|
|
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,
|
|
)
|
|
from app.services.user.mock_customer_service import hydrate_mock_customer_from_cpf
|
|
|
|
|
|
class OrderFlowMixin:
|
|
def _has_explicit_order_request(self, message: str) -> bool:
|
|
normalized = self._normalize_text(message).strip()
|
|
order_terms = {
|
|
"comprar",
|
|
"compra",
|
|
"pedido",
|
|
"pedir",
|
|
"financiar",
|
|
"financiamento",
|
|
"simular compra",
|
|
"realizar pedido",
|
|
"fazer um pedido",
|
|
}
|
|
return any(term in normalized for term in order_terms)
|
|
|
|
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 _try_prefill_order_cpf_from_memory(self, user_id: int | None, payload: dict) -> None:
|
|
if user_id is None or payload.get("cpf"):
|
|
return
|
|
|
|
context = self._get_user_context(user_id)
|
|
if not context:
|
|
return
|
|
memory = context.get("generic_memory", {})
|
|
cpf = memory.get("cpf")
|
|
if isinstance(cpf, str) and self._is_valid_cpf(cpf):
|
|
payload["cpf"] = cpf
|
|
|
|
def _try_prefill_order_cpf_from_user_profile(self, user_id: int | None, payload: dict) -> None:
|
|
if user_id is None or payload.get("cpf"):
|
|
return
|
|
|
|
db = SessionMockLocal()
|
|
try:
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if user and isinstance(user.cpf, str) and self._is_valid_cpf(user.cpf):
|
|
payload["cpf"] = user.cpf
|
|
finally:
|
|
db.close()
|
|
|
|
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)
|
|
explicit_order_request = self._has_explicit_order_request(message)
|
|
|
|
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 draft is None and not (has_intent and explicit_order_request):
|
|
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. Pode me informar novamente?"
|
|
if cpf_value:
|
|
try:
|
|
await hydrate_mock_customer_from_cpf(
|
|
cpf=str(cpf_value),
|
|
user_id=user_id,
|
|
)
|
|
except ValueError:
|
|
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. Pode me informar novamente?"
|
|
|
|
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)
|
|
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)
|
|
self.state.pop_entry("pending_cancel_order_drafts", user_id)
|
|
|
|
return self._fallback_format_tool_result("cancelar_pedido", tool_result)
|