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.
375 lines
15 KiB
Python
375 lines
15 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, Vehicle
|
|
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_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 _get_last_stock_results(self, user_id: int | None) -> list[dict]:
|
|
context = self._get_user_context(user_id)
|
|
if not context:
|
|
return []
|
|
stock_results = context.get("last_stock_results") or []
|
|
return stock_results if isinstance(stock_results, list) else []
|
|
|
|
def _get_selected_vehicle(self, user_id: int | None) -> dict | None:
|
|
context = self._get_user_context(user_id)
|
|
if not context:
|
|
return None
|
|
selected_vehicle = context.get("selected_vehicle")
|
|
return dict(selected_vehicle) if isinstance(selected_vehicle, dict) else None
|
|
|
|
def _store_selected_vehicle(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["selected_vehicle"] = dict(vehicle) if isinstance(vehicle, dict) else None
|
|
|
|
def _vehicle_to_payload(self, vehicle: dict) -> dict:
|
|
return {
|
|
"vehicle_id": int(vehicle["id"]),
|
|
"modelo_veiculo": str(vehicle["modelo"]),
|
|
"valor_veiculo": round(float(vehicle["preco"]), 2),
|
|
}
|
|
|
|
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
|
|
selected_vehicle = self._get_selected_vehicle(user_id=user_id)
|
|
if selected_vehicle:
|
|
payload.update(self._vehicle_to_payload(selected_vehicle))
|
|
|
|
def _match_vehicle_from_message_index(self, message: str, stock_results: list[dict]) -> dict | None:
|
|
tokens = [token for token in re.findall(r"\d+", str(message or "")) if token.isdigit()]
|
|
if not tokens:
|
|
return None
|
|
choice = int(tokens[0])
|
|
if 1 <= choice <= len(stock_results):
|
|
return stock_results[choice - 1]
|
|
return None
|
|
|
|
def _match_vehicle_from_message_model(self, message: str, stock_results: list[dict]) -> dict | None:
|
|
normalized_message = self._normalize_text(message)
|
|
matches = []
|
|
for item in stock_results:
|
|
normalized_model = self._normalize_text(str(item.get("modelo") or ""))
|
|
if normalized_model and normalized_model in normalized_message:
|
|
matches.append(item)
|
|
if len(matches) == 1:
|
|
return matches[0]
|
|
return None
|
|
|
|
def _load_vehicle_by_id(self, vehicle_id: int) -> dict | None:
|
|
db = SessionMockLocal()
|
|
try:
|
|
vehicle = db.query(Vehicle).filter(Vehicle.id == vehicle_id).first()
|
|
if not vehicle:
|
|
return None
|
|
return {
|
|
"id": int(vehicle.id),
|
|
"modelo": str(vehicle.modelo),
|
|
"categoria": str(vehicle.categoria),
|
|
"preco": float(vehicle.preco),
|
|
}
|
|
finally:
|
|
db.close()
|
|
|
|
def _try_resolve_order_vehicle(self, message: str, user_id: int | None, payload: dict) -> dict | None:
|
|
vehicle_id = payload.get("vehicle_id")
|
|
if isinstance(vehicle_id, int) and vehicle_id > 0:
|
|
return self._load_vehicle_by_id(vehicle_id)
|
|
|
|
stock_results = self._get_last_stock_results(user_id=user_id)
|
|
selected_from_model = self._match_vehicle_from_message_model(message=message, stock_results=stock_results)
|
|
if selected_from_model:
|
|
return selected_from_model
|
|
|
|
selected_from_index = self._match_vehicle_from_message_index(message=message, stock_results=stock_results)
|
|
if selected_from_index:
|
|
return selected_from_index
|
|
|
|
normalized_model = self._normalize_text(str(payload.get("modelo_veiculo") or ""))
|
|
if normalized_model:
|
|
matches = [
|
|
item
|
|
for item in stock_results
|
|
if self._normalize_text(str(item.get("modelo") or "")) == normalized_model
|
|
]
|
|
if len(matches) == 1:
|
|
return matches[0]
|
|
|
|
return None
|
|
|
|
def _render_missing_order_fields_prompt(self, missing_fields: list[str]) -> str:
|
|
labels = {
|
|
"cpf": "o CPF do cliente",
|
|
"vehicle_id": "qual veiculo do estoque voce quer comprar",
|
|
}
|
|
itens = [f"- {labels[field]}" for field in missing_fields]
|
|
return "Para realizar o pedido, preciso dos dados abaixo:\n" + "\n".join(itens)
|
|
|
|
def _render_vehicle_selection_from_stock_prompt(self, stock_results: list[dict]) -> str:
|
|
lines = ["Para realizar o pedido, escolha primeiro qual veiculo voce quer comprar:"]
|
|
for idx, item in enumerate(stock_results[:5], start=1):
|
|
lines.append(
|
|
f"- {idx}. [{item.get('id', 'N/A')}] {item.get('modelo', 'N/A')} "
|
|
f"({item.get('categoria', 'N/A')}) - R$ {float(item.get('preco', 0)):.2f}"
|
|
)
|
|
lines.append("Pode responder com o numero da lista ou com o modelo do veiculo.")
|
|
return "\n".join(lines)
|
|
|
|
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_cpf_from_memory(user_id=user_id, payload=draft["payload"])
|
|
self._try_prefill_order_vehicle_from_context(user_id=user_id, payload=draft["payload"])
|
|
|
|
resolved_vehicle = self._try_resolve_order_vehicle(
|
|
message=message,
|
|
user_id=user_id,
|
|
payload=draft["payload"],
|
|
)
|
|
if resolved_vehicle:
|
|
self._store_selected_vehicle(user_id=user_id, vehicle=resolved_vehicle)
|
|
draft["payload"].update(self._vehicle_to_payload(resolved_vehicle))
|
|
|
|
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?"
|
|
|
|
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:
|
|
if "vehicle_id" in missing:
|
|
stock_results = self._get_last_stock_results(user_id=user_id)
|
|
if stock_results:
|
|
return self._render_vehicle_selection_from_stock_prompt(stock_results)
|
|
return self._render_missing_order_fields_prompt(missing)
|
|
|
|
try:
|
|
tool_result = await self.registry.execute(
|
|
"realizar_pedido",
|
|
{
|
|
"cpf": draft["payload"]["cpf"],
|
|
"vehicle_id": draft["payload"]["vehicle_id"],
|
|
},
|
|
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)
|
|
active_order_draft = self.state.get_entry("pending_order_drafts", user_id, expire=True)
|
|
|
|
extracted = self._normalize_cancel_order_fields(extracted_fields)
|
|
has_intent = normalized_intents.get("order_cancel", False)
|
|
|
|
if (
|
|
draft is None
|
|
and active_order_draft is not None
|
|
and (
|
|
not has_intent
|
|
or ("numero_pedido" not in extracted and "motivo" not in extracted)
|
|
)
|
|
):
|
|
return None
|
|
|
|
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)
|