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.
811 lines
34 KiB
Python
811 lines
34 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,
|
|
RENTAL_REQUIRED_FIELDS,
|
|
)
|
|
from app.services.flows.rental_flow_support import RentalFlowStateSupport
|
|
|
|
|
|
class RentalFlowMixin:
|
|
@property
|
|
def _rental_flow_state_support(self) -> RentalFlowStateSupport:
|
|
support = getattr(self, "__rental_flow_state_support", None)
|
|
if support is None:
|
|
support = RentalFlowStateSupport(self)
|
|
setattr(self, "__rental_flow_state_support", support)
|
|
return support
|
|
|
|
def _rental_now(self) -> datetime:
|
|
provider = getattr(self, "_rental_now_provider", None)
|
|
if callable(provider):
|
|
return provider()
|
|
return datetime.now()
|
|
|
|
# Corrige variacoes corrompidas comuns de datas relativas vindas de canais externos.
|
|
def _normalize_rental_relative_text(self, text: str) -> str:
|
|
normalized = technical_normalizer.normalize_text(text)
|
|
replacements = (
|
|
(r"depois\s+de\s+amanh\?", "depois de amanha"),
|
|
(r"amanh\?", "amanha"),
|
|
(r"hoj\?", "hoje"),
|
|
(r"\bat\?\b", "ate"),
|
|
)
|
|
for pattern, replacement in replacements:
|
|
normalized = re.sub(pattern, replacement, normalized)
|
|
return normalized
|
|
# Sanitiza resultados da frota antes de guardar no contexto.
|
|
def _sanitize_rental_results(self, rental_results: list[dict] | None) -> list[dict]:
|
|
return self._rental_flow_state_support.sanitize_rental_results(rental_results)
|
|
|
|
# Marca locacao como dominio ativo na conversa do usuario.
|
|
def _mark_rental_flow_active(self, user_id: int | None, *, active_task: str | None = None) -> None:
|
|
self._rental_flow_state_support.mark_rental_flow_active(
|
|
user_id=user_id,
|
|
active_task=active_task,
|
|
)
|
|
|
|
# Recupera a ultima lista de veiculos disponiveis para locacao.
|
|
def _get_last_rental_results(self, user_id: int | None) -> list[dict]:
|
|
return self._rental_flow_state_support.get_last_rental_results(user_id=user_id)
|
|
|
|
# Guarda a lista atual para permitir selecao do veiculo em mensagens seguintes.
|
|
def _store_pending_rental_selection(
|
|
self,
|
|
user_id: int | None,
|
|
rental_results: list[dict] | None,
|
|
search_payload: dict | None = None,
|
|
) -> None:
|
|
self._rental_flow_state_support.store_pending_rental_selection(
|
|
user_id=user_id,
|
|
rental_results=rental_results,
|
|
search_payload=search_payload,
|
|
)
|
|
|
|
# Recupera o ultimo snapshot de busca de locacao salvo no contexto.
|
|
def _get_last_rental_search_payload(self, user_id: int | None) -> dict:
|
|
if user_id is None:
|
|
return {}
|
|
|
|
pending_selection = self.state.get_entry("pending_rental_selections", user_id, expire=True)
|
|
if isinstance(pending_selection, dict):
|
|
pending_search_payload = self._sanitize_rental_search_payload(pending_selection.get("search_payload"))
|
|
if pending_search_payload:
|
|
return pending_search_payload
|
|
|
|
if not hasattr(self, "_get_user_context"):
|
|
return {}
|
|
context = self._get_user_context(user_id)
|
|
if not isinstance(context, dict):
|
|
return {}
|
|
return self._sanitize_rental_search_payload(context.get("last_rental_search_payload"))
|
|
|
|
# Persiste no contexto os campos reutilizaveis da busca de locacao.
|
|
def _store_last_rental_search_payload(self, user_id: int | None, payload) -> None:
|
|
if user_id is None or not hasattr(self, "_get_user_context") or not hasattr(self, "_save_user_context"):
|
|
return
|
|
context = self._get_user_context(user_id)
|
|
if not isinstance(context, dict):
|
|
return
|
|
sanitized = self._sanitize_rental_search_payload(payload)
|
|
if sanitized:
|
|
context["last_rental_search_payload"] = sanitized
|
|
else:
|
|
context.pop("last_rental_search_payload", None)
|
|
self._save_user_context(user_id=user_id, context=context)
|
|
|
|
# Le o veiculo de locacao escolhido que ficou salvo no contexto.
|
|
def _get_selected_rental_vehicle(self, user_id: int | None) -> dict | None:
|
|
return self._rental_flow_state_support.get_selected_rental_vehicle(user_id=user_id)
|
|
|
|
# Filtra o payload do contrato para manter so dados uteis no contexto.
|
|
def _sanitize_rental_contract_snapshot(self, payload) -> dict | None:
|
|
return self._rental_flow_state_support.sanitize_rental_contract_snapshot(payload)
|
|
|
|
# Filtra apenas os campos da busca que podem ser reaproveitados antes da escolha do veiculo.
|
|
def _sanitize_rental_search_payload(self, payload) -> dict:
|
|
if not isinstance(payload, dict):
|
|
return {}
|
|
sanitized: dict = {}
|
|
|
|
category = self._extract_rental_category_from_text(str(payload.get("categoria") or ""))
|
|
if category:
|
|
sanitized["categoria"] = category
|
|
|
|
plate = technical_normalizer.normalize_plate(payload.get("placa"))
|
|
if plate:
|
|
sanitized["placa"] = plate
|
|
|
|
cpf = technical_normalizer.normalize_cpf(payload.get("cpf"))
|
|
if cpf:
|
|
sanitized["cpf"] = cpf
|
|
|
|
model_hint = str(payload.get("modelo") or "").strip(" ,.;")
|
|
if model_hint and not self._extract_rental_category_from_text(model_hint):
|
|
sanitized["modelo"] = model_hint.title()
|
|
|
|
budget = technical_normalizer.normalize_positive_number(payload.get("valor_diaria_max"))
|
|
if budget is not None:
|
|
sanitized["valor_diaria_max"] = float(budget)
|
|
|
|
for field_name in ("data_inicio", "data_fim_prevista"):
|
|
normalized = self._normalize_rental_datetime_text(payload.get(field_name))
|
|
if normalized:
|
|
sanitized[field_name] = normalized
|
|
|
|
return sanitized
|
|
|
|
# Recupera o ultimo contrato de locacao lembrado para o usuario.
|
|
def _get_last_rental_contract(self, user_id: int | None) -> dict | None:
|
|
return self._rental_flow_state_support.get_last_rental_contract(user_id=user_id)
|
|
|
|
# Atualiza o ultimo contrato de locacao salvo no contexto.
|
|
def _store_last_rental_contract(self, user_id: int | None, payload) -> None:
|
|
self._rental_flow_state_support.store_last_rental_contract(
|
|
user_id=user_id,
|
|
payload=payload,
|
|
)
|
|
|
|
# Persiste a ultima consulta de frota para reuso no fluxo incremental.
|
|
def _remember_rental_results(self, user_id: int | None, rental_results: list[dict] | None) -> None:
|
|
self._rental_flow_state_support.remember_rental_results(
|
|
user_id=user_id,
|
|
rental_results=rental_results,
|
|
)
|
|
|
|
# Salva o veiculo escolhido e encerra a etapa de selecao pendente.
|
|
def _store_selected_rental_vehicle(self, user_id: int | None, vehicle: dict | None) -> None:
|
|
self._rental_flow_state_support.store_selected_rental_vehicle(
|
|
user_id=user_id,
|
|
vehicle=vehicle,
|
|
)
|
|
|
|
# Converte um veiculo selecionado no payload esperado pela abertura da locacao.
|
|
def _rental_vehicle_to_payload(self, vehicle: dict) -> dict:
|
|
return self._rental_flow_state_support.rental_vehicle_to_payload(vehicle)
|
|
|
|
# Extrai a categoria de locacao mencionada livremente pelo usuario.
|
|
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
|
|
|
|
# Extrai um modelo ou marca/modelo quando o pedido for mais especifico.
|
|
def _extract_rental_model_from_text(self, text: str) -> str | None:
|
|
normalized = self._normalize_rental_relative_text(text).strip()
|
|
if not normalized:
|
|
return None
|
|
|
|
normalized = re.sub(r"\b\d{1,2}[/-]\d{1,2}[/-]\d{4}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?\b", " ", normalized)
|
|
normalized = re.sub(r"\b\d{4}[/-]\d{1,2}[/-]\d{1,2}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?\b", " ", normalized)
|
|
normalized = re.sub(r"\b[a-z]{3}\d[a-z0-9]\d{2}\b", " ", normalized)
|
|
normalized = re.sub(r"\br\$\s*\d+[\d\.,]*\b", " ", normalized)
|
|
normalized = re.sub(
|
|
r"\b(?:depois\s+de\s+amanh(?:a)?|day\s+after\s+tomorrow|amanh(?:a)?|tomorrow|hoj(?:e)?|today)"
|
|
r"(?:\s+(?:as|a))?"
|
|
r"(?:\s+(?:\d{1,2}:\d{2}(?::\d{2})?|\d{1,2}\s*(?:h|hora|horas)))?\b",
|
|
" ",
|
|
normalized,
|
|
)
|
|
|
|
category = self._extract_rental_category_from_text(normalized)
|
|
if category:
|
|
normalized = re.sub(rf"(?<![a-z0-9]){re.escape(category)}(?![a-z0-9])", " ", normalized)
|
|
if category == "pickup":
|
|
normalized = re.sub(r"(?<![a-z0-9])picape(?![a-z0-9])", " ", normalized)
|
|
|
|
candidate = None
|
|
cue_patterns = (
|
|
r"(?:quero|gostaria|preciso|procuro|procurando|busco|buscando)\s+(?:alugar|locar)?\s*(?:um|uma|o|a)?\s*(?P<candidate>.+)",
|
|
r"(?:tem|ha|existe|existem|mostre|mostrar|liste|listar|quais)\s+(?:um|uma|o|a)?\s*(?P<candidate>.+)",
|
|
r"(?P<candidate>.+?)\s+(?:para\s+aluguel|para\s+locacao)\b",
|
|
)
|
|
for pattern in cue_patterns:
|
|
match = re.search(pattern, normalized)
|
|
if match:
|
|
candidate = str(match.group("candidate") or "").strip()
|
|
if candidate:
|
|
break
|
|
if not candidate:
|
|
return None
|
|
|
|
boundary_tokens = {
|
|
"para",
|
|
"pra",
|
|
"com",
|
|
"sem",
|
|
"que",
|
|
"por",
|
|
"de",
|
|
"do",
|
|
"da",
|
|
"dos",
|
|
"das",
|
|
"no",
|
|
"na",
|
|
"nos",
|
|
"nas",
|
|
"automatico",
|
|
"automatica",
|
|
"automaticos",
|
|
"automaticas",
|
|
"manual",
|
|
"manuais",
|
|
"barato",
|
|
"barata",
|
|
"economico",
|
|
"economica",
|
|
"ate",
|
|
"at",
|
|
}
|
|
generic_tokens = {
|
|
"aluguel",
|
|
"alugar",
|
|
"locacao",
|
|
"locar",
|
|
"carro",
|
|
"carros",
|
|
"veiculo",
|
|
"veiculos",
|
|
"modelo",
|
|
"categoria",
|
|
"tipo",
|
|
"disponiveis",
|
|
"disponivel",
|
|
"frota",
|
|
"opcoes",
|
|
"opcao",
|
|
"esta",
|
|
"estao",
|
|
"estava",
|
|
"estavam",
|
|
"existe",
|
|
"existem",
|
|
"ha",
|
|
"tem",
|
|
"um",
|
|
"uma",
|
|
"o",
|
|
"a",
|
|
"os",
|
|
"as",
|
|
"suv",
|
|
"sedan",
|
|
"hatch",
|
|
"pickup",
|
|
"picape",
|
|
}
|
|
|
|
tokens: list[str] = []
|
|
for token in re.findall(r"[a-z0-9]+", candidate):
|
|
if token in boundary_tokens:
|
|
break
|
|
if token in generic_tokens:
|
|
continue
|
|
if re.fullmatch(r"(?:19|20)\d{2}", token):
|
|
continue
|
|
if re.fullmatch(r"\d{1,2}h", token):
|
|
continue
|
|
if len(token) < 2:
|
|
continue
|
|
tokens.append(token)
|
|
if len(tokens) >= 3:
|
|
break
|
|
|
|
if not tokens:
|
|
return None
|
|
|
|
return " ".join(tokens).title().strip() or None
|
|
|
|
# Coleta datas de locacao em texto livre mantendo a ordem encontrada.
|
|
def _extract_rental_datetimes_from_text(self, text: str) -> list[str]:
|
|
normalized = technical_normalizer.normalize_datetime_connector(
|
|
self._normalize_rental_relative_text(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",
|
|
)
|
|
matches: list[tuple[int, str]] = []
|
|
for pattern in patterns:
|
|
for match in re.finditer(pattern, normalized):
|
|
candidate = self._normalize_rental_datetime_text(match.group(0))
|
|
if candidate:
|
|
matches.append((match.start(), candidate))
|
|
|
|
relative_pattern = (
|
|
r"\b(?:depois\s+de\s+amanh(?:a)?|day\s+after\s+tomorrow|amanh(?:a)?|tomorrow|hoj(?:e)?|today)"
|
|
r"(?:\s+(?:as|a))?"
|
|
r"(?:\s+(?:\d{1,2}:\d{2}(?::\d{2})?|\d{1,2}\s*(?:h|hora|horas)))?"
|
|
)
|
|
for match in re.finditer(relative_pattern, normalized):
|
|
candidate = self._normalize_rental_datetime_text(match.group(0))
|
|
if candidate:
|
|
matches.append((match.start(), candidate))
|
|
|
|
results: list[str] = []
|
|
seen: set[str] = set()
|
|
for _, candidate in sorted(matches, key=lambda item: item[0]):
|
|
if candidate in seen:
|
|
continue
|
|
seen.add(candidate)
|
|
results.append(candidate)
|
|
return results
|
|
# Normaliza datas de locacao para um formato unico aceito pelo fluxo.
|
|
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:
|
|
normalized = self._normalize_rental_relative_text(text)
|
|
day_offset = None
|
|
if "depois de amanha" in normalized or "depois de amanh" in normalized or "day after tomorrow" in normalized:
|
|
day_offset = 2
|
|
elif "amanha" in normalized or "amanh" in normalized or "tomorrow" in normalized:
|
|
day_offset = 1
|
|
elif "hoje" in normalized or "hoj" in normalized or "today" in normalized:
|
|
day_offset = 0
|
|
if day_offset is None:
|
|
return None
|
|
|
|
time_text = technical_normalizer.extract_hhmm_from_text(normalized)
|
|
if not time_text:
|
|
return None
|
|
|
|
hour_text, minute_text = time_text.split(":")
|
|
current_datetime = self._rental_now()
|
|
target_date = current_datetime + timedelta(days=day_offset)
|
|
return f"{target_date.strftime('%d/%m/%Y')} {int(hour_text):02d}:{int(minute_text):02d}"
|
|
if ":" in text:
|
|
return parsed.strftime("%d/%m/%Y %H:%M")
|
|
return parsed.strftime("%d/%m/%Y")
|
|
# Normaliza campos estruturados de aluguel antes de montar o draft.
|
|
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
|
|
|
|
model_hint = str(data.get("modelo") or data.get("modelo_veiculo") or "").strip(" ,.;")
|
|
if model_hint and not self._extract_rental_category_from_text(model_hint):
|
|
payload["modelo"] = model_hint.title()
|
|
|
|
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
|
|
|
|
# Enriquece o draft com placa, cpf, categoria, budget e datas extraidos da mensagem.
|
|
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)
|
|
|
|
if payload.get("modelo") is None:
|
|
model_hint = self._extract_rental_model_from_text(message)
|
|
if model_hint:
|
|
payload["modelo"] = model_hint
|
|
|
|
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]
|
|
|
|
# Detecta pedidos para listar a frota de aluguel.
|
|
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)
|
|
|
|
# Detecta quando o usuario quer iniciar uma nova locacao.
|
|
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)
|
|
|
|
# Detecta pedidos de devolucao ou encerramento da locacao.
|
|
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"})
|
|
|
|
# Detecta quando a mensagem parece tratar de pagamento ou multa de aluguel.
|
|
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"})
|
|
|
|
# Interpreta selecoes numericas com base na ultima lista apresentada.
|
|
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
|
|
|
|
# Tenta casar a resposta do usuario com modelo ou placa da frota mostrada.
|
|
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
|
|
|
|
# Resolve o veiculo escolhido reaproveitando contexto e texto livre.
|
|
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
|
|
|
|
# Decide se a mensagem atual pode continuar uma selecao de aluguel ja iniciada.
|
|
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)
|
|
)
|
|
)
|
|
|
|
# Monta a pergunta objetiva com os campos que faltam para abrir a locacao.
|
|
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)
|
|
|
|
# Formata a lista curta da frota para o usuario escolher um veiculo.
|
|
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)
|
|
|
|
# Semeia o draft da locacao quando a frota e listada pelo caminho generico.
|
|
def _seed_pending_rental_draft_from_message(self, message: str, user_id: int | None) -> None:
|
|
if user_id is None or not self._has_explicit_rental_request(message):
|
|
return
|
|
|
|
draft = self.state.get_entry("pending_rental_drafts", user_id, expire=True)
|
|
if not isinstance(draft, dict):
|
|
draft = {
|
|
"payload": {},
|
|
"expires_at": utc_now() + timedelta(minutes=PENDING_RENTAL_DRAFT_TTL_MINUTES),
|
|
}
|
|
|
|
payload = draft.get("payload")
|
|
if not isinstance(payload, dict):
|
|
payload = {}
|
|
draft["payload"] = payload
|
|
|
|
self._try_capture_rental_fields_from_message(message=message, payload=payload)
|
|
if not payload:
|
|
return
|
|
|
|
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")
|
|
self._store_last_rental_search_payload(user_id=user_id, payload=payload)
|
|
rental_results = self._get_last_rental_results(user_id=user_id)
|
|
if rental_results:
|
|
self._store_pending_rental_selection(
|
|
user_id=user_id,
|
|
rental_results=rental_results,
|
|
search_payload=payload,
|
|
)
|
|
|
|
# Consulta a frota e guarda o resultado para a etapa de selecao.
|
|
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,
|
|
}
|
|
category = payload.get("categoria") or self._extract_rental_category_from_text(message)
|
|
if category:
|
|
arguments["categoria"] = str(category).strip().lower()
|
|
|
|
model_hint = str(payload.get("modelo") or self._extract_rental_model_from_text(message) or "").strip()
|
|
if model_hint:
|
|
arguments["modelo"] = model_hint
|
|
|
|
arguments["ordenar_diaria"] = "asc" if (category or model_hint) else "random"
|
|
|
|
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._store_pending_rental_selection(
|
|
user_id=user_id,
|
|
rental_results=rental_results,
|
|
search_payload=payload,
|
|
)
|
|
self._mark_rental_flow_active(user_id=user_id)
|
|
return self._fallback_format_tool_result("consultar_frota_aluguel", tool_result)
|
|
|
|
# Conduz a coleta incremental dos dados e abre a locacao quando estiver completa.
|
|
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
|
|
|
|
remembered_search_payload = self._get_last_rental_search_payload(user_id=user_id)
|
|
if draft is None and remembered_search_payload:
|
|
draft = {
|
|
"payload": dict(remembered_search_payload),
|
|
"expires_at": utc_now() + timedelta(minutes=PENDING_RENTAL_DRAFT_TTL_MINUTES),
|
|
}
|
|
|
|
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")
|
|
self._store_last_rental_search_payload(user_id=user_id, payload=draft_payload)
|
|
|
|
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)
|
|
if hasattr(self, "_capture_successful_tool_side_effects"):
|
|
self._capture_successful_tool_side_effects(
|
|
tool_name="abrir_locacao_aluguel",
|
|
arguments={
|
|
"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"),
|
|
},
|
|
tool_result=tool_result,
|
|
user_id=user_id,
|
|
)
|
|
return self._fallback_format_tool_result("abrir_locacao_aluguel", tool_result)
|