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

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)