🐛 fix(rental): blindar fluxo ativo e refinar busca por modelo

Evita acoes indevidas de devolucao e pagamento herdadas so pelo contexto recente do aluguel.

Refina a identificacao de modelo para ignorar anos e pedidos genericos, mantendo a listagem aleatoria quando nao houver preferencia especifica.

Adiciona regressoes para follow-ups de locacao, filtros de frota e limpeza de contexto.
main
parent 2c4e1dd688
commit aa3bc3f3e0

@ -1,4 +1,5 @@
import math
import random
import re
from datetime import datetime
from typing import Any
@ -12,16 +13,19 @@ from app.services.orchestration import technical_normalizer
from app.services.user.mock_customer_service import hydrate_mock_customer_from_cpf
# Normaliza o numero do contrato para comparacoes e buscas.
def _normalize_contract_number(value: str | None) -> str | None:
text = str(value or "").strip().upper()
return text or None
# Limpa campos textuais livres antes de salvar ou responder.
def _normalize_text_field(value: str | None) -> str | None:
text = str(value or "").strip(" ,.;")
return text or None
# Converte datas opcionais de aluguel em datetime com formatos aceitos.
def _parse_optional_datetime(value: str | None, *, field_name: str) -> datetime | None:
text = str(value or "").strip()
if not text:
@ -58,6 +62,7 @@ def _parse_optional_datetime(value: str | None, *, field_name: str) -> datetime
return None
# Exige uma data obrigatoria de aluguel e reaproveita a validacao comum.
def _parse_required_datetime(value: str | None, *, field_name: str) -> datetime:
parsed = _parse_optional_datetime(value, field_name=field_name)
if parsed is None:
@ -71,6 +76,7 @@ def _parse_required_datetime(value: str | None, *, field_name: str) -> datetime:
return parsed
# Valida e normaliza valores monetarios positivos usados no fluxo.
def _normalize_money(value) -> float:
number = technical_normalizer.normalize_positive_number(value)
if number is None or float(number) <= 0:
@ -84,6 +90,7 @@ def _normalize_money(value) -> float:
return float(number)
# Garante que o identificador do veiculo seja um inteiro positivo.
def _normalize_vehicle_id(value) -> int | None:
if value is None or value == "":
return None
@ -108,6 +115,7 @@ def _normalize_vehicle_id(value) -> int | None:
return numeric
# Calcula a quantidade de diarias cobradas entre inicio e fim da locacao.
def _calculate_rental_days(start: datetime, end: datetime) -> int:
delta_seconds = (end - start).total_seconds()
if delta_seconds < 0:
@ -123,6 +131,7 @@ def _calculate_rental_days(start: datetime, end: datetime) -> int:
return max(1, math.ceil(delta_seconds / 86400))
# Busca o veiculo da locacao por id ou placa normalizada.
def _lookup_rental_vehicle(
db,
*,
@ -146,6 +155,7 @@ def _lookup_rental_vehicle(
return None
# Prioriza contratos do proprio usuario antes de cair para contratos sem dono.
def _lookup_contract_by_user_preference(query, user_id: int | None):
if user_id is None:
return query.order_by(RentalContract.created_at.desc()).first()
@ -157,6 +167,7 @@ def _lookup_contract_by_user_preference(query, user_id: int | None):
return query.filter(RentalContract.user_id.is_(None)).order_by(RentalContract.created_at.desc()).first()
# Resolve um contrato de aluguel usando contrato, placa ou contexto do usuario.
def _resolve_rental_contract(
db,
*,
@ -195,6 +206,7 @@ def _resolve_rental_contract(
return None
# Lista a frota de aluguel com filtros simples e ordenacao configuravel.
async def consultar_frota_aluguel(
categoria: str | None = None,
valor_diaria_max: float | None = None,
@ -221,22 +233,33 @@ async def consultar_frota_aluguel(
if modelo:
query = query.filter(RentalVehicle.modelo.ilike(f"%{str(modelo).strip()}%"))
if ordenar_diaria in {"asc", "desc"}:
order_mode = str(ordenar_diaria or "").strip().lower()
normalized_limit = None
if limite is not None:
try:
normalized_limit = max(1, min(int(limite), 50))
except (TypeError, ValueError):
normalized_limit = None
if order_mode in {"asc", "desc"}:
query = query.order_by(
RentalVehicle.valor_diaria.asc()
if ordenar_diaria == "asc"
if order_mode == "asc"
else RentalVehicle.valor_diaria.desc()
)
else:
elif order_mode != "random":
query = query.order_by(RentalVehicle.valor_diaria.asc(), RentalVehicle.modelo.asc())
if limite is not None:
try:
query = query.limit(max(1, min(int(limite), 50)))
except (TypeError, ValueError):
pass
if order_mode == "random":
rows = query.all()
random.shuffle(rows)
if normalized_limit is not None:
rows = rows[:normalized_limit]
else:
if normalized_limit is not None:
query = query.limit(normalized_limit)
rows = query.all()
return [
{
"id": row.id,
@ -253,6 +276,7 @@ async def consultar_frota_aluguel(
db.close()
# Abre uma locacao, reserva o veiculo e devolve o resumo do contrato.
async def abrir_locacao_aluguel(
data_inicio: str,
data_fim_prevista: str,
@ -353,6 +377,7 @@ async def abrir_locacao_aluguel(
db.close()
# Encerra a locacao ativa, calcula o valor final e libera o veiculo.
async def registrar_devolucao_aluguel(
contrato_numero: str | None = None,
placa: str | None = None,
@ -417,6 +442,7 @@ async def registrar_devolucao_aluguel(
db.close()
# Registra um pagamento de aluguel e tenta vincular o contrato correto.
async def registrar_pagamento_aluguel(
valor: float,
contrato_numero: str | None = None,
@ -488,6 +514,7 @@ async def registrar_pagamento_aluguel(
db.close()
# Registra uma multa ligada ao aluguel usando os identificadores disponiveis.
async def registrar_multa_aluguel(
valor: float,
placa: str | None = None,

@ -13,6 +13,7 @@ from app.services.orchestration.orchestrator_config import (
class RentalFlowMixin:
# Sanitiza resultados da frota antes de guardar no contexto.
def _sanitize_rental_results(self, rental_results: list[dict] | None) -> list[dict]:
sanitized: list[dict] = []
for item in rental_results or []:
@ -40,6 +41,7 @@ class RentalFlowMixin:
)
return sanitized
# 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:
if user_id is None:
return
@ -51,6 +53,7 @@ class RentalFlowMixin:
context["active_task"] = active_task
self._save_user_context(user_id=user_id, context=context)
# Recupera a ultima lista de veiculos disponiveis para locacao.
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):
@ -65,6 +68,7 @@ class RentalFlowMixin:
rental_results = context.get("last_rental_results") or []
return self._sanitize_rental_results(rental_results if isinstance(rental_results, list) else [])
# 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) -> None:
if user_id is None:
return
@ -81,6 +85,7 @@ class RentalFlowMixin:
},
)
# Le o veiculo de locacao escolhido que ficou salvo no contexto.
def _get_selected_rental_vehicle(self, user_id: int | None) -> dict | None:
context = self._get_user_context(user_id)
if not isinstance(context, dict):
@ -88,6 +93,7 @@ class RentalFlowMixin:
selected_vehicle = context.get("selected_rental_vehicle")
return dict(selected_vehicle) if isinstance(selected_vehicle, dict) else None
# Filtra o payload do contrato para manter so dados uteis no contexto.
def _sanitize_rental_contract_snapshot(self, payload) -> dict | None:
if not isinstance(payload, dict):
return None
@ -123,6 +129,7 @@ class RentalFlowMixin:
return snapshot
# Recupera o ultimo contrato de locacao lembrado para o usuario.
def _get_last_rental_contract(self, user_id: int | None) -> dict | None:
context = self._get_user_context(user_id)
if not isinstance(context, dict):
@ -130,6 +137,7 @@ class RentalFlowMixin:
contract = context.get("last_rental_contract")
return dict(contract) if isinstance(contract, dict) else None
# Atualiza o ultimo contrato de locacao salvo no contexto.
def _store_last_rental_contract(self, user_id: int | None, payload) -> None:
if user_id is None:
return
@ -144,6 +152,7 @@ class RentalFlowMixin:
self._save_user_context(user_id=user_id, context=context)
# 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:
context = self._get_user_context(user_id)
if not isinstance(context, dict):
@ -156,6 +165,7 @@ class RentalFlowMixin:
context["active_domain"] = "rental"
self._save_user_context(user_id=user_id, context=context)
# 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:
if user_id is None:
return
@ -167,6 +177,7 @@ class RentalFlowMixin:
self.state.pop_entry("pending_rental_selections", user_id)
self._save_user_context(user_id=user_id, context=context)
# Converte um veiculo selecionado no payload esperado pela abertura da locacao.
def _rental_vehicle_to_payload(self, vehicle: dict) -> dict:
return {
"rental_vehicle_id": int(vehicle["id"]),
@ -176,6 +187,7 @@ class RentalFlowMixin:
"valor_diaria": round(float(vehicle.get("valor_diaria") or 0), 2),
}
# 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 = {
@ -190,6 +202,123 @@ class RentalFlowMixin:
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_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)
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",
}
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 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(text)
patterns = (
@ -204,6 +333,7 @@ class RentalFlowMixin:
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:
@ -228,6 +358,7 @@ class RentalFlowMixin:
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 {}
@ -261,6 +392,10 @@ class RentalFlowMixin:
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:
@ -268,6 +403,7 @@ class RentalFlowMixin:
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 ""))
@ -292,6 +428,11 @@ class RentalFlowMixin:
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"):
@ -302,6 +443,7 @@ class RentalFlowMixin:
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()
@ -312,6 +454,7 @@ class RentalFlowMixin:
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"}):
@ -330,14 +473,17 @@ class RentalFlowMixin:
}
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:
@ -347,6 +493,7 @@ class RentalFlowMixin:
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 = []
@ -361,6 +508,7 @@ class RentalFlowMixin:
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:
@ -385,6 +533,7 @@ class RentalFlowMixin:
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
@ -403,6 +552,7 @@ class RentalFlowMixin:
)
)
# 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",
@ -412,6 +562,7 @@ class RentalFlowMixin:
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):
@ -423,6 +574,7 @@ class RentalFlowMixin:
lines.append("Pode responder com o numero da lista, com a placa ou com o modelo.")
return "\n".join(lines)
# Consulta a frota e guarda o resultado para a etapa de selecao.
async def _try_list_rental_fleet_for_selection(
self,
message: str,
@ -438,12 +590,17 @@ class RentalFlowMixin:
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()
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)
@ -464,6 +621,7 @@ class RentalFlowMixin:
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,

@ -673,6 +673,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
return None
return await finish(response)
# Continua a abertura de locacao quando o usuario responde a uma lista pendente.
async def _try_handle_pending_rental_selection_follow_up(
self,
message: str,
@ -705,6 +706,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
return None
return await finish(response)
# Consome respostas curtas enquanto um fluxo de locacao estiver ativo.
async def _try_handle_active_rental_follow_up(
self,
message: str,
@ -719,6 +721,11 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
if str(context.get("active_domain") or "").strip().lower() != "rental":
return None
pending_rental_draft = self.state.get_entry("pending_rental_drafts", user_id, expire=True)
pending_rental_selection = self.state.get_entry("pending_rental_selections", user_id, expire=True)
if not pending_rental_draft and not pending_rental_selection:
return None
normalized_message = self.normalizer.normalize_text(message).strip()
if self._looks_like_explicit_domain_shift_request(normalized_message):
return None
@ -726,16 +733,19 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
self._has_order_listing_request(message)
or self._has_explicit_order_request(message)
or self._has_stock_listing_request(message)
or self._has_rental_return_request(message)
or (
self._has_rental_return_management_request(message, user_id=user_id)
and not self._looks_like_pending_rental_due_date_follow_up(
message=message,
user_id=user_id,
pending_rental_draft=pending_rental_draft,
context=context,
)
)
or self._has_rental_payment_or_fine_request(message)
):
return None
pending_rental_draft = self.state.get_entry("pending_rental_drafts", user_id, expire=True)
pending_rental_selection = self.state.get_entry("pending_rental_selections", user_id, expire=True)
if not pending_rental_draft and not pending_rental_selection:
return None
response = await self._try_collect_and_open_rental(
message=message,
user_id=user_id,
@ -751,6 +761,33 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
return await finish(response)
return None
# Reconhece quando "devolucao ..." ainda e so a data prevista do draft atual.
def _looks_like_pending_rental_due_date_follow_up(
self,
message: str,
user_id: int | None,
pending_rental_draft,
context: dict | None = None,
) -> bool:
if user_id is None or not isinstance(pending_rental_draft, dict):
return False
if not isinstance(context, dict):
context = self._get_user_context(user_id)
if not isinstance(context, dict) or context.get("active_task") != "rental_create":
return False
payload = pending_rental_draft.get("payload")
if not isinstance(payload, dict) or payload.get("data_fim_prevista"):
return False
if not (payload.get("data_inicio") or payload.get("rental_vehicle_id") or payload.get("placa")):
return False
normalized_message = self._normalize_text(message).strip()
if "devolucao" not in normalized_message:
return False
return bool(self._extract_rental_datetimes_from_text(message))
# Limpa valores extraidos do texto e descarta marcadores vazios ou placeholders.
def _clean_extracted_rental_value(self, value: str | None) -> str | None:
text = str(value or "").strip(" \t\r\n.;,")
if not text:
@ -777,6 +814,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
return None
return text
# Extrai valores rotulados do texto no formato campo: valor.
def _extract_rental_labeled_value(self, text: str, labels: tuple[str, ...]) -> str | None:
if not labels:
return None
@ -790,6 +828,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
return None
return self._clean_extracted_rental_value(match.group("value"))
# Localiza o numero do contrato de locacao em texto livre ou rotulado.
def _extract_rental_contract_number_from_text(self, text: str) -> str | None:
match = re.search(r"\bLOC-[A-Z0-9-]+\b", str(text or ""), flags=re.IGNORECASE)
if match:
@ -802,6 +841,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
return str(labeled_match.group(0)).strip().upper()
return None
# Tenta descobrir a placa do aluguel a partir da mensagem atual.
def _extract_rental_plate_from_text(self, text: str) -> str | None:
labeled_value = self._extract_rental_labeled_value(text, ("placa",))
if labeled_value:
@ -813,6 +853,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
self._try_capture_rental_fields_from_message(message=text, payload=extracted)
return self._normalize_plate(extracted.get("placa"))
# Complementa argumentos com contrato ou placa lembrados no contexto recente.
def _merge_last_rental_reference(self, user_id: int | None, arguments: dict) -> dict:
if not isinstance(arguments, dict):
return {}
@ -825,18 +866,62 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
arguments["placa"] = str(last_contract["placa"])
return arguments
# Evita tratar perguntas sobre devolucao como se fossem um encerramento real.
def _looks_like_rental_return_question(self, message: str) -> bool:
normalized_message = self._normalize_text(message).strip()
if "devolucao" not in normalized_message and "devolver" not in normalized_message:
return False
question_terms = (
"qual",
"quais",
"quando",
"como",
"posso",
"pode",
"consigo",
"me lembra",
"me informe",
"me diz",
"me diga",
)
return normalized_message.endswith("?") or any(term in normalized_message for term in question_terms)
# Detecta pedidos para registrar devolucao de locacao.
def _has_rental_return_management_request(self, message: str, user_id: int | None = None) -> bool:
if not self._has_rental_return_request(message):
return False
normalized_message = self._normalize_text(message).strip()
return bool(
if self._looks_like_rental_return_question(normalized_message):
return False
has_reference_in_message = bool(
"aluguel" in normalized_message
or "locacao" in normalized_message
or self._get_last_rental_contract(user_id)
or self._extract_rental_contract_number_from_text(message)
or self._extract_rental_plate_from_text(message)
)
explicit_action_terms = (
"devolver",
"registrar devolucao",
"registrar a devolucao",
"encerrar locacao",
"fechar locacao",
"finalizar locacao",
)
has_explicit_action = any(term in normalized_message for term in explicit_action_terms)
if has_reference_in_message and (
has_explicit_action
or (
"devolucao" in normalized_message
and bool(self._extract_rental_datetimes_from_text(message))
)
):
return True
return bool(self._get_last_rental_contract(user_id) and has_explicit_action)
# Detecta pedidos para registrar pagamento de aluguel.
def _has_rental_payment_request(self, message: str, user_id: int | None = None) -> bool:
normalized_message = self._normalize_text(message).strip()
if "multa" in normalized_message:
@ -847,10 +932,11 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
return bool(
"aluguel" in normalized_message
or "locacao" in normalized_message
or self._get_last_rental_contract(user_id)
or self._extract_rental_contract_number_from_text(message)
or self._extract_rental_plate_from_text(message)
)
# Detecta pedidos para registrar multa vinculada ao aluguel.
def _has_rental_fine_request(self, message: str, user_id: int | None = None) -> bool:
normalized_message = self._normalize_text(message).strip()
if "multa" not in normalized_message:
@ -859,11 +945,11 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
"aluguel" in normalized_message
or "locacao" in normalized_message
or "auto_infracao" in normalized_message
or self._get_last_rental_contract(user_id)
or self._extract_rental_contract_number_from_text(message)
or self._extract_rental_plate_from_text(message)
)
# Decide se a mensagem pode virar uma acao de aluguel sem depender do planner.
def _is_deterministic_rental_management_candidate(self, message: str, user_id: int | None) -> bool:
has_policy = hasattr(self, "policy") and getattr(self, "policy") is not None
if has_policy and user_id is not None and (
@ -876,6 +962,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
or self._has_rental_fine_request(message, user_id=user_id)
)
# Monta os argumentos da devolucao a partir do texto enviado pelo usuario.
def _build_rental_return_arguments_from_message(self, message: str, user_id: int | None) -> dict:
arguments: dict = {}
contract_number = self._extract_rental_contract_number_from_text(message)
@ -893,6 +980,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
arguments["data_devolucao"] = date_text
return self._merge_last_rental_reference(user_id=user_id, arguments=arguments)
# Monta os argumentos do pagamento de aluguel com base no texto extraido.
def _build_rental_payment_arguments_from_message(self, message: str, user_id: int | None) -> dict:
arguments: dict = {}
contract_number = self._extract_rental_contract_number_from_text(message)
@ -932,6 +1020,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
return self._merge_last_rental_reference(user_id=user_id, arguments=arguments)
# Monta os argumentos da multa de aluguel a partir da mensagem recebida.
def _build_rental_fine_arguments_from_message(self, message: str, user_id: int | None) -> dict:
arguments: dict = {}
contract_number = self._extract_rental_contract_number_from_text(message)
@ -978,6 +1067,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
return self._merge_last_rental_reference(user_id=user_id, arguments=arguments)
# Executa devolucao, pagamento ou multa de aluguel quando os dados ja estiverem claros.
async def _try_handle_deterministic_rental_management(
self,
message: str,
@ -1253,6 +1343,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
queue_notice=queue_notice,
)
# Limpa drafts e selecoes de locacao quando o fluxo termina ou e abortado.
def _reset_pending_rental_states(self, user_id: int | None) -> None:
if user_id is None:
return
@ -1333,6 +1424,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
context["selected_vehicle"] = None
context["last_rental_results"] = []
context["selected_rental_vehicle"] = None
context.pop("last_rental_contract", None)
self._save_user_context(user_id=user_id, context=context)
def _clear_pending_order_navigation(self, user_id: int | None) -> int:
@ -1449,7 +1541,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
async def _tool_continuar_proximo_pedido(self, user_id: int | None = None) -> str:
return await self._continue_next_order_now(user_id=user_id)
# Nessa função é onde eu configuro a memória volátil do sistema
# Nessa funcao eu configuro a memoria volatil do sistema
def _upsert_user_context(self, user_id: int | None) -> None:
self.state.upsert_user_context(user_id=user_id, ttl_minutes=USER_CONTEXT_TTL_MINUTES)
@ -1919,6 +2011,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
)
return any(term in normalized_message for term in shift_terms)
# Define quando o atendimento deve priorizar a continuidade do fluxo de locacao.
def _should_prioritize_rental_flow(
self,
turn_decision: dict | None,

@ -1,4 +1,4 @@
import os
import os
import unittest
from datetime import datetime, timedelta
from app.core.time_utils import utc_now
@ -1859,10 +1859,83 @@ class RentalFlowDraftTests(unittest.IsolatedAsyncioTestCase):
)
self.assertEqual(registry.calls[0][0], "consultar_frota_aluguel")
self.assertEqual(registry.calls[0][1]["ordenar_diaria"], "random")
self.assertIn("veiculo(s) para locacao", response)
self.assertIsNotNone(state.get_entry("pending_rental_selections", 21))
self.assertEqual(state.get_user_context(21)["active_domain"], "rental")
async def test_rental_flow_filters_fleet_by_category_when_user_requests_suv(self):
state = FakeState(contexts={21: self._base_context()})
registry = FakeRegistry()
flow = RentalFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_open_rental(
message="quais suv estao disponiveis para aluguel",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "rental_list", "domain": "rental", "action": "answer_user"},
)
self.assertEqual(registry.calls[0][0], "consultar_frota_aluguel")
self.assertEqual(registry.calls[0][1]["categoria"], "suv")
self.assertEqual(registry.calls[0][1]["ordenar_diaria"], "asc")
self.assertIn("veiculo(s) para locacao", response)
async def test_rental_flow_filters_fleet_by_model_when_user_requests_specific_vehicle(self):
state = FakeState(contexts={21: self._base_context()})
registry = FakeRegistry()
flow = RentalFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_open_rental(
message="quero alugar um chevrolet tracker",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "rental_create", "domain": "rental", "action": "answer_user"},
)
self.assertEqual(registry.calls[0][0], "consultar_frota_aluguel")
self.assertEqual(registry.calls[0][1]["modelo"], "Chevrolet Tracker")
self.assertEqual(registry.calls[0][1]["ordenar_diaria"], "asc")
self.assertIn("veiculo(s) para locacao", response)
async def test_rental_flow_ignores_vehicle_year_when_filtering_specific_model(self):
state = FakeState(contexts={21: self._base_context()})
registry = FakeRegistry()
flow = RentalFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_open_rental(
message="quero alugar um fiat pulse 2024",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "rental_create", "domain": "rental", "action": "answer_user"},
)
self.assertEqual(registry.calls[0][0], "consultar_frota_aluguel")
self.assertEqual(registry.calls[0][1]["modelo"], "Fiat Pulse")
self.assertEqual(registry.calls[0][1]["ordenar_diaria"], "asc")
self.assertIn("veiculo(s) para locacao", response)
async def test_rental_flow_keeps_generic_listing_when_request_is_not_a_specific_model(self):
state = FakeState(contexts={21: self._base_context()})
registry = FakeRegistry()
flow = RentalFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_open_rental(
message="quero alugar um carro para viajar com a familia",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "rental_create", "domain": "rental", "action": "answer_user"},
)
self.assertEqual(registry.calls[0][0], "consultar_frota_aluguel")
self.assertNotIn("modelo", registry.calls[0][1])
self.assertEqual(registry.calls[0][1]["ordenar_diaria"], "random")
self.assertIn("veiculo(s) para locacao", response)
async def test_rental_flow_accepts_vehicle_selection_from_list_index(self):
state = FakeState(
entries={

@ -88,6 +88,40 @@ class RentalServiceTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(result[0]["placa"], "AAA1A11")
self.assertEqual(result[0]["status"], "disponivel")
async def test_consultar_frota_aluguel_filtra_por_modelo(self):
SessionLocal = self._build_session_local()
db = SessionLocal()
try:
self._create_rental_vehicle(db, placa="AAA1A11", modelo="Chevrolet Tracker")
self._create_rental_vehicle(db, placa="BBB2B22", modelo="Fiat Pulse")
finally:
db.close()
with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal):
result = await rental_service.consultar_frota_aluguel(modelo="tracker")
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["modelo"], "Chevrolet Tracker")
async def test_consultar_frota_aluguel_randomiza_resultados_quando_solicitado(self):
SessionLocal = self._build_session_local()
db = SessionLocal()
try:
self._create_rental_vehicle(db, placa="AAA1A11", modelo="Chevrolet Tracker", valor_diaria=219.9)
self._create_rental_vehicle(db, placa="BBB2B22", modelo="Fiat Pulse", valor_diaria=189.9)
self._create_rental_vehicle(db, placa="CCC3C33", modelo="Renault Kwid", valor_diaria=119.9)
finally:
db.close()
with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal), patch(
"app.services.domain.rental_service.random.shuffle",
side_effect=lambda items: items.reverse(),
):
result = await rental_service.consultar_frota_aluguel(ordenar_diaria="random", limite=2)
self.assertEqual(len(result), 2)
self.assertEqual([item["placa"] for item in result], ["CCC3C33", "BBB2B22"])
async def test_abrir_locacao_aluguel_cria_contrato_e_marca_veiculo_como_alugado(self):
SessionLocal = self._build_session_local()
db = SessionLocal()

@ -2289,6 +2289,89 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("inicio da locacao", response)
async def test_handle_message_keeps_rental_create_flow_when_user_informs_due_date_with_devolucao_label(self):
state = FakeState(
entries={
"pending_rental_drafts": {
1: {
"payload": {
"rental_vehicle_id": 3,
"placa": "RAA1A02",
"data_inicio": "19/03/2026 10:00",
},
"expires_at": utc_now() + timedelta(minutes=15),
}
}
},
contexts={
1: {
"active_domain": "rental",
"active_task": "rental_create",
"generic_memory": {},
"shared_memory": {},
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
"last_rental_results": [],
"selected_rental_vehicle": {"id": 3, "placa": "RAA1A02", "modelo": "Fiat Pulse"},
"last_rental_contract": {
"contrato_numero": "LOC-20260319-33CD6567",
"placa": "RAA1A02",
},
}
}
)
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
service.policy = ConversationPolicy(service=service)
service._empty_extraction_payload = service.normalizer.empty_extraction_payload
service._log_turn_event = lambda *args, **kwargs: None
service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response
service._get_user_context = lambda user_id: state.get_user_context(user_id)
service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context)
async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None):
return base_response
service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order
service._upsert_user_context = lambda user_id: None
async def fake_extract_turn_decision(message: str, user_id: int | None):
raise AssertionError("nao deveria consultar o LLM durante follow-up ativo de locacao")
service._extract_turn_decision_with_llm = fake_extract_turn_decision
async def fake_try_handle_immediate_context_reset(**kwargs):
return None
service._try_handle_immediate_context_reset = fake_try_handle_immediate_context_reset
async def fake_try_resolve_pending_order_selection(**kwargs):
return None
service._try_resolve_pending_order_selection = fake_try_resolve_pending_order_selection
async def fake_try_continue_queued_order(**kwargs):
return None
service._try_continue_queued_order = fake_try_continue_queued_order
async def fake_try_collect_and_open_rental(**kwargs):
self.assertEqual(kwargs["message"], "devolucao 21/03/2026 10:00")
return "locacao aberta"
service._try_collect_and_open_rental = fake_try_collect_and_open_rental
response = await service.handle_message(
"devolucao 21/03/2026 10:00",
user_id=1,
)
self.assertEqual(response, "locacao aberta")
async def test_handle_message_short_circuits_for_rental_return_using_last_contract(self):
state = FakeState(
contexts={
@ -2601,6 +2684,69 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
],
)
def test_has_rental_return_management_request_ignores_return_question_even_with_last_contract(self):
state = FakeState(
contexts={
1: {
"active_domain": "general",
"generic_memory": {},
"shared_memory": {},
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
"last_rental_results": [],
"selected_rental_vehicle": None,
"last_rental_contract": {
"contrato_numero": "LOC-20260318-FE69BCF0",
"placa": "RAA1A12",
},
}
}
)
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
service._get_user_context = lambda user_id: state.get_user_context(user_id)
self.assertFalse(
service._has_rental_return_management_request(
"qual a data de devolucao do meu aluguel?",
user_id=1,
)
)
def test_has_rental_payment_request_requires_current_rental_reference(self):
state = FakeState(
contexts={
1: {
"active_domain": "general",
"generic_memory": {},
"shared_memory": {},
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
"last_rental_results": [],
"selected_rental_vehicle": None,
"last_rental_contract": {
"contrato_numero": "LOC-20260318-FE69BCF0",
"placa": "RAA1A12",
},
}
}
)
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
service._get_user_context = lambda user_id: state.get_user_context(user_id)
self.assertFalse(service._has_rental_payment_request("segue comprovante pix de R$ 500", user_id=1))
self.assertTrue(service._has_rental_payment_request("segue comprovante do aluguel de R$ 500", user_id=1))
async def test_handle_message_keeps_sales_flow_when_cpf_follow_up_is_misclassified_as_review(self):
state = FakeState(
entries={
@ -3072,6 +3218,10 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
"pending_switch": None,
"last_stock_results": [{"id": 7, "modelo": "Fiat Argo 2020", "categoria": "suv", "preco": 61857.0}],
"selected_vehicle": {"id": 7, "modelo": "Fiat Argo 2020", "categoria": "suv", "preco": 61857.0},
"last_rental_contract": {
"contrato_numero": "LOC-20260319-33CD6567",
"placa": "RAA1A02",
},
}
}
)
@ -3100,6 +3250,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response, "Contexto da conversa limpo. Podemos recomecar do zero.")
self.assertEqual(state.get_user_context(1)["active_domain"], "general")
self.assertEqual(state.get_user_context(1)["generic_memory"], {})
self.assertIsNone(state.get_user_context(1).get("last_rental_contract"))
async def test_active_sales_follow_up_ignores_order_listing_request_with_open_order_draft(self):
state = FakeState(
@ -3673,6 +3824,10 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
"pending_switch": None,
"last_stock_results": [{"id": 7, "modelo": "Fiat Argo 2020", "categoria": "suv", "preco": 61857.0}],
"selected_vehicle": {"id": 7, "modelo": "Fiat Argo 2020", "categoria": "suv", "preco": 61857.0},
"last_rental_contract": {
"contrato_numero": "LOC-20260319-33CD6567",
"placa": "RAA1A02",
},
}
}
)

Loading…
Cancel
Save