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/rental_flow.py

565 lines
24 KiB
Python

import re
from datetime import datetime, timedelta
from fastapi import HTTPException
from app.core.time_utils import utc_now
from app.services.orchestration import technical_normalizer
from app.services.orchestration.orchestrator_config import (
PENDING_RENTAL_DRAFT_TTL_MINUTES,
PENDING_RENTAL_SELECTION_TTL_MINUTES,
RENTAL_REQUIRED_FIELDS,
)
class RentalFlowMixin:
def _sanitize_rental_results(self, rental_results: list[dict] | None) -> list[dict]:
sanitized: list[dict] = []
for item in rental_results or []:
if not isinstance(item, dict):
continue
try:
rental_vehicle_id = int(item.get("id"))
valor_diaria = float(item.get("valor_diaria") or 0)
ano = int(item.get("ano")) if item.get("ano") is not None else None
except (TypeError, ValueError):
continue
placa = technical_normalizer.normalize_plate(item.get("placa"))
if not placa:
continue
sanitized.append(
{
"id": rental_vehicle_id,
"placa": placa,
"modelo": str(item.get("modelo") or "").strip(),
"categoria": str(item.get("categoria") or "").strip().lower(),
"ano": ano,
"valor_diaria": valor_diaria,
"status": str(item.get("status") or "").strip().lower() or "disponivel",
}
)
return sanitized
def _mark_rental_flow_active(self, user_id: int | None, *, active_task: str | None = None) -> None:
if user_id is None:
return
context = self._get_user_context(user_id)
if not isinstance(context, dict):
return
context["active_domain"] = "rental"
if active_task is not None:
context["active_task"] = active_task
self._save_user_context(user_id=user_id, context=context)
def _get_last_rental_results(self, user_id: int | None) -> list[dict]:
pending_selection = self.state.get_entry("pending_rental_selections", user_id, expire=True)
if isinstance(pending_selection, dict):
payload = pending_selection.get("payload")
if isinstance(payload, list):
sanitized = self._sanitize_rental_results(payload)
if sanitized:
return sanitized
context = self._get_user_context(user_id)
if not isinstance(context, dict):
return []
rental_results = context.get("last_rental_results") or []
return self._sanitize_rental_results(rental_results if isinstance(rental_results, list) else [])
def _store_pending_rental_selection(self, user_id: int | None, rental_results: list[dict] | None) -> None:
if user_id is None:
return
sanitized = self._sanitize_rental_results(rental_results)
if not sanitized:
self.state.pop_entry("pending_rental_selections", user_id)
return
self.state.set_entry(
"pending_rental_selections",
user_id,
{
"payload": sanitized,
"expires_at": utc_now() + timedelta(minutes=PENDING_RENTAL_SELECTION_TTL_MINUTES),
},
)
def _get_selected_rental_vehicle(self, user_id: int | None) -> dict | None:
context = self._get_user_context(user_id)
if not isinstance(context, dict):
return None
selected_vehicle = context.get("selected_rental_vehicle")
return dict(selected_vehicle) if isinstance(selected_vehicle, dict) else None
def _sanitize_rental_contract_snapshot(self, payload) -> dict | None:
if not isinstance(payload, dict):
return None
contract_number = str(payload.get("contrato_numero") or "").strip().upper()
plate = technical_normalizer.normalize_plate(payload.get("placa"))
if not contract_number and not plate:
return None
snapshot: dict = {}
if contract_number:
snapshot["contrato_numero"] = contract_number
if plate:
snapshot["placa"] = plate
for field_name in (
"modelo_veiculo",
"categoria",
"status",
"status_veiculo",
"data_inicio",
"data_fim_prevista",
"data_devolucao",
):
value = str(payload.get(field_name) or "").strip()
if value:
snapshot[field_name] = value
for field_name in ("valor_diaria", "valor_previsto", "valor_final"):
number = technical_normalizer.normalize_positive_number(payload.get(field_name))
if number is not None:
snapshot[field_name] = float(number)
return snapshot
def _get_last_rental_contract(self, user_id: int | None) -> dict | None:
context = self._get_user_context(user_id)
if not isinstance(context, dict):
return None
contract = context.get("last_rental_contract")
return dict(contract) if isinstance(contract, dict) else None
def _store_last_rental_contract(self, user_id: int | None, payload) -> None:
if user_id is None:
return
context = self._get_user_context(user_id)
if not isinstance(context, dict):
return
sanitized = self._sanitize_rental_contract_snapshot(payload)
if sanitized is None:
context.pop("last_rental_contract", None)
else:
context["last_rental_contract"] = sanitized
self._save_user_context(user_id=user_id, context=context)
def _remember_rental_results(self, user_id: int | None, rental_results: list[dict] | None) -> None:
context = self._get_user_context(user_id)
if not isinstance(context, dict):
return
sanitized = self._sanitize_rental_results(rental_results)
context["last_rental_results"] = sanitized
self._store_pending_rental_selection(user_id=user_id, rental_results=sanitized)
if sanitized:
context["selected_rental_vehicle"] = None
context["active_domain"] = "rental"
self._save_user_context(user_id=user_id, context=context)
def _store_selected_rental_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 isinstance(context, dict):
return
context["selected_rental_vehicle"] = dict(vehicle) if isinstance(vehicle, dict) else None
context["active_domain"] = "rental"
self.state.pop_entry("pending_rental_selections", user_id)
self._save_user_context(user_id=user_id, context=context)
def _rental_vehicle_to_payload(self, vehicle: dict) -> dict:
return {
"rental_vehicle_id": int(vehicle["id"]),
"placa": str(vehicle["placa"]),
"modelo_veiculo": str(vehicle["modelo"]),
"categoria": str(vehicle.get("categoria") or ""),
"valor_diaria": round(float(vehicle.get("valor_diaria") or 0), 2),
}
def _extract_rental_category_from_text(self, text: str) -> str | None:
normalized = self._normalize_text(text).strip()
aliases = {
"suv": "suv",
"sedan": "sedan",
"hatch": "hatch",
"pickup": "pickup",
"picape": "pickup",
}
for token, category in aliases.items():
if re.search(rf"(?<![a-z0-9]){re.escape(token)}(?![a-z0-9])", normalized):
return category
return None
def _extract_rental_datetimes_from_text(self, text: str) -> list[str]:
normalized = technical_normalizer.normalize_datetime_connector(text)
patterns = (
r"\b\d{1,2}[/-]\d{1,2}[/-]\d{4}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?\b",
r"\b\d{4}[/-]\d{1,2}[/-]\d{1,2}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?\b",
)
results: list[str] = []
for pattern in patterns:
for match in re.finditer(pattern, normalized):
candidate = self._normalize_rental_datetime_text(match.group(0))
if candidate and candidate not in results:
results.append(candidate)
return results
def _normalize_rental_datetime_text(self, value) -> str | None:
text = technical_normalizer.normalize_datetime_connector(str(value or "").strip())
if not text:
return None
parsed = technical_normalizer.try_parse_iso_datetime(text)
if parsed is None:
parsed = technical_normalizer.try_parse_datetime_with_formats(
text,
(
"%d/%m/%Y %H:%M",
"%d/%m/%Y %H:%M:%S",
"%d/%m/%Y",
"%Y-%m-%d %H:%M",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%d",
),
)
if parsed is None:
return None
if ":" in text:
return parsed.strftime("%d/%m/%Y %H:%M")
return parsed.strftime("%d/%m/%Y")
def _normalize_rental_fields(self, data) -> dict:
if not isinstance(data, dict):
return {}
payload: dict = {}
rental_vehicle_id = data.get("rental_vehicle_id")
if rental_vehicle_id is None:
rental_vehicle_id = data.get("vehicle_id")
try:
if rental_vehicle_id not in (None, ""):
numeric = int(rental_vehicle_id)
if numeric > 0:
payload["rental_vehicle_id"] = numeric
except (TypeError, ValueError):
pass
placa = technical_normalizer.normalize_plate(data.get("placa"))
if placa:
payload["placa"] = placa
cpf = technical_normalizer.normalize_cpf(data.get("cpf"))
if cpf:
payload["cpf"] = cpf
valor_diaria_max = technical_normalizer.normalize_positive_number(data.get("valor_diaria_max"))
if valor_diaria_max:
payload["valor_diaria_max"] = float(valor_diaria_max)
categoria = self._extract_rental_category_from_text(str(data.get("categoria") or ""))
if categoria:
payload["categoria"] = categoria
for field_name in ("data_inicio", "data_fim_prevista"):
normalized = self._normalize_rental_datetime_text(data.get(field_name))
if normalized:
payload[field_name] = normalized
return payload
def _try_capture_rental_fields_from_message(self, message: str, payload: dict) -> None:
if payload.get("placa") is None:
words = re.findall(r"[A-Za-z0-9-]+", str(message or ""))
for word in words:
plate = technical_normalizer.normalize_plate(word)
if plate:
payload["placa"] = plate
break
if payload.get("cpf") is None:
cpf = technical_normalizer.extract_cpf_from_text(message)
if cpf and technical_normalizer.is_valid_cpf(cpf):
payload["cpf"] = cpf
if payload.get("categoria") is None:
category = self._extract_rental_category_from_text(message)
if category:
payload["categoria"] = category
if payload.get("valor_diaria_max") is None:
budget = technical_normalizer.extract_budget_from_text(message)
if budget:
payload["valor_diaria_max"] = float(budget)
datetimes = self._extract_rental_datetimes_from_text(message)
if datetimes:
if not payload.get("data_inicio"):
payload["data_inicio"] = datetimes[0]
if len(datetimes) >= 2 and not payload.get("data_fim_prevista"):
payload["data_fim_prevista"] = datetimes[1]
elif len(datetimes) == 1 and payload.get("data_inicio") and not payload.get("data_fim_prevista"):
if payload["data_inicio"] != datetimes[0]:
payload["data_fim_prevista"] = datetimes[0]
def _has_rental_listing_request(self, message: str, turn_decision: dict | None = None) -> bool:
decision_intent = self._decision_intent(turn_decision)
decision_domain = str((turn_decision or {}).get("domain") or "").strip().lower()
if decision_domain == "rental" and decision_intent in {"rental_list", "rental_search"}:
return True
normalized = self._normalize_text(message).strip()
rental_terms = {"aluguel", "alugar", "locacao", "locar"}
listing_terms = {"quais", "listar", "liste", "mostrar", "mostre", "disponiveis", "disponivel", "frota", "opcoes", "opcao"}
return any(term in normalized for term in rental_terms) and any(term in normalized for term in listing_terms)
def _has_explicit_rental_request(self, message: str) -> bool:
normalized = self._normalize_text(message).strip()
if any(term in normalized for term in {"multa", "comprovante", "pagamento", "devolucao", "devolver"}):
return False
request_terms = {
"quero alugar",
"quero locar",
"abrir locacao",
"abrir aluguel",
"fazer locacao",
"iniciar locacao",
"seguir com a locacao",
"seguir com aluguel",
"alugar o carro",
"locacao do carro",
}
return any(term in normalized for term in request_terms)
def _has_rental_return_request(self, message: str) -> bool:
normalized = self._normalize_text(message).strip()
return any(term in normalized for term in {"devolver", "devolucao", "encerrar locacao", "fechar locacao"})
def _has_rental_payment_or_fine_request(self, message: str) -> bool:
normalized = self._normalize_text(message).strip()
return any(term in normalized for term in {"multa", "comprovante", "pagamento", "boleto", "pix"})
def _match_rental_vehicle_from_message_index(self, message: str, rental_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(rental_results):
return rental_results[choice - 1]
return None
def _match_rental_vehicle_from_message_model(self, message: str, rental_results: list[dict]) -> dict | None:
normalized_message = self._normalize_text(message)
matches = []
for item in rental_results:
normalized_model = self._normalize_text(str(item.get("modelo") or ""))
normalized_plate = self._normalize_text(str(item.get("placa") or ""))
if (normalized_model and normalized_model in normalized_message) or (
normalized_plate and normalized_plate in normalized_message
):
matches.append(item)
if len(matches) == 1:
return matches[0]
return None
def _try_resolve_rental_vehicle(self, message: str, user_id: int | None, payload: dict) -> dict | None:
rental_vehicle_id = payload.get("rental_vehicle_id")
if isinstance(rental_vehicle_id, int) and rental_vehicle_id > 0:
for item in self._get_last_rental_results(user_id=user_id):
if int(item.get("id") or 0) == rental_vehicle_id:
return item
rental_results = self._get_last_rental_results(user_id=user_id)
selected_from_model = self._match_rental_vehicle_from_message_model(message=message, rental_results=rental_results)
if selected_from_model:
return selected_from_model
selected_from_index = self._match_rental_vehicle_from_message_index(message=message, rental_results=rental_results)
if selected_from_index:
return selected_from_index
normalized_plate = technical_normalizer.normalize_plate(payload.get("placa"))
if normalized_plate:
matches = [item for item in rental_results if str(item.get("placa") or "").strip().upper() == normalized_plate]
if len(matches) == 1:
return matches[0]
return None
def _should_bootstrap_rental_from_context(self, message: str, user_id: int | None, payload: dict | None = None) -> bool:
if user_id is None:
return False
rental_results = self._get_last_rental_results(user_id=user_id)
if not rental_results:
return False
normalized_payload = payload if isinstance(payload, dict) else {}
return bool(
self._match_rental_vehicle_from_message_model(message=message, rental_results=rental_results)
or self._match_rental_vehicle_from_message_index(message=message, rental_results=rental_results)
or (
normalized_payload.get("placa")
and self._try_resolve_rental_vehicle(message=message, user_id=user_id, payload=normalized_payload)
)
)
def _render_missing_rental_fields_prompt(self, missing_fields: list[str]) -> str:
labels = {
"rental_vehicle_id": "qual veiculo da frota voce quer alugar",
"data_inicio": "a data e hora de inicio da locacao",
"data_fim_prevista": "a data e hora previstas para devolucao",
}
items = [f"- {labels[field]}" for field in missing_fields]
return "Para abrir a locacao, preciso dos dados abaixo:\n" + "\n".join(items)
def _render_rental_selection_from_fleet_prompt(self, rental_results: list[dict]) -> str:
lines = ["Para seguir com a locacao, escolha primeiro qual veiculo voce quer alugar:"]
for idx, item in enumerate(rental_results[:10], start=1):
lines.append(
f"- {idx}. {item.get('modelo', 'N/A')} {item.get('ano', 'N/A')} | "
f"{item.get('placa', 'N/A')} | {item.get('categoria', 'N/A')} | "
f"diaria R$ {float(item.get('valor_diaria', 0)):.2f}"
)
lines.append("Pode responder com o numero da lista, com a placa ou com o modelo.")
return "\n".join(lines)
async def _try_list_rental_fleet_for_selection(
self,
message: str,
user_id: int | None,
payload: dict,
turn_decision: dict | None = None,
force: bool = False,
) -> str | None:
if user_id is None:
return None
if not force and not self._has_rental_listing_request(message, turn_decision=turn_decision):
return None
arguments: dict = {
"limite": 10,
"ordenar_diaria": "asc",
}
category = payload.get("categoria") or self._extract_rental_category_from_text(message)
if category:
arguments["categoria"] = str(category).strip().lower()
valor_diaria_max = payload.get("valor_diaria_max")
if not isinstance(valor_diaria_max, (int, float)):
valor_diaria_max = technical_normalizer.extract_budget_from_text(message)
if isinstance(valor_diaria_max, (int, float)) and float(valor_diaria_max) > 0:
arguments["valor_diaria_max"] = float(valor_diaria_max)
try:
tool_result = await self.tool_executor.execute(
"consultar_frota_aluguel",
arguments,
user_id=user_id,
)
except HTTPException as exc:
return self._http_exception_detail(exc)
rental_results = tool_result if isinstance(tool_result, list) else []
self._remember_rental_results(user_id=user_id, rental_results=rental_results)
self._mark_rental_flow_active(user_id=user_id)
return self._fallback_format_tool_result("consultar_frota_aluguel", tool_result)
async def _try_collect_and_open_rental(
self,
message: str,
user_id: int | None,
extracted_fields: dict | None = None,
intents: dict | None = None,
turn_decision: dict | None = None,
) -> str | None:
if user_id is None:
return None
draft = self.state.get_entry("pending_rental_drafts", user_id, expire=True)
extracted = self._normalize_rental_fields(extracted_fields)
decision_intent = self._decision_intent(turn_decision)
has_intent = decision_intent in {"rental_create", "rental_list", "rental_search"}
explicit_rental_request = self._has_explicit_rental_request(message)
rental_listing_request = self._has_rental_listing_request(message, turn_decision=turn_decision)
should_bootstrap_from_context = draft is None and self._should_bootstrap_rental_from_context(
message=message,
user_id=user_id,
payload=extracted,
)
if (
draft is None
and not has_intent
and not explicit_rental_request
and not rental_listing_request
and not should_bootstrap_from_context
):
return None
if draft is None:
draft = {
"payload": {},
"expires_at": utc_now() + timedelta(minutes=PENDING_RENTAL_DRAFT_TTL_MINUTES),
}
draft_payload = draft.get("payload", {})
if not isinstance(draft_payload, dict):
draft_payload = {}
draft["payload"] = draft_payload
draft_payload.update(extracted)
self._try_capture_rental_fields_from_message(message=message, payload=draft_payload)
selected_vehicle = self._get_selected_rental_vehicle(user_id=user_id)
if selected_vehicle and not draft_payload.get("rental_vehicle_id"):
draft_payload.update(self._rental_vehicle_to_payload(selected_vehicle))
resolved_vehicle = self._try_resolve_rental_vehicle(
message=message,
user_id=user_id,
payload=draft_payload,
)
if resolved_vehicle:
self._store_selected_rental_vehicle(user_id=user_id, vehicle=resolved_vehicle)
draft_payload.update(self._rental_vehicle_to_payload(resolved_vehicle))
draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_RENTAL_DRAFT_TTL_MINUTES)
self.state.set_entry("pending_rental_drafts", user_id, draft)
self._mark_rental_flow_active(user_id=user_id, active_task="rental_create")
missing = [field for field in RENTAL_REQUIRED_FIELDS if field not in draft_payload]
if missing:
if "rental_vehicle_id" in missing:
fleet_response = await self._try_list_rental_fleet_for_selection(
message=message,
user_id=user_id,
payload=draft_payload,
turn_decision=turn_decision,
force=bool(draft) or explicit_rental_request or rental_listing_request or should_bootstrap_from_context,
)
if fleet_response:
return fleet_response
rental_results = self._get_last_rental_results(user_id=user_id)
if rental_results:
return self._render_rental_selection_from_fleet_prompt(rental_results)
return self._render_missing_rental_fields_prompt(missing)
try:
tool_result = await self.tool_executor.execute(
"abrir_locacao_aluguel",
{
"rental_vehicle_id": draft_payload["rental_vehicle_id"],
"placa": draft_payload.get("placa"),
"data_inicio": draft_payload["data_inicio"],
"data_fim_prevista": draft_payload["data_fim_prevista"],
"cpf": draft_payload.get("cpf"),
},
user_id=user_id,
)
except HTTPException as exc:
return self._http_exception_detail(exc)
self._store_last_rental_contract(user_id=user_id, payload=tool_result)
self._reset_pending_rental_states(user_id=user_id)
return self._fallback_format_tool_result("abrir_locacao_aluguel", tool_result)