From aa3bc3f3e0b72d8b80c10e3dc2432a57c2bf4d30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vitor=20Hugo=20Belorio=20Sim=C3=A3o?= Date: Thu, 19 Mar 2026 12:10:13 -0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(rental):=20blindar=20fluxo?= =?UTF-8?q?=20ativo=20e=20refinar=20busca=20por=20modelo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/services/domain/rental_service.py | 45 ++++- app/services/flows/rental_flow.py | 160 +++++++++++++++++- .../orchestration/orquestrador_service.py | 115 +++++++++++-- tests/test_conversation_adjustments.py | 75 +++++++- tests/test_rental_service.py | 34 ++++ tests/test_turn_decision_contract.py | 155 +++++++++++++++++ 6 files changed, 562 insertions(+), 22 deletions(-) diff --git a/app/services/domain/rental_service.py b/app/services/domain/rental_service.py index a67e885..7a20f60 100644 --- a/app/services/domain/rental_service.py +++ b/app/services/domain/rental_service.py @@ -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() - 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, diff --git a/app/services/flows/rental_flow.py b/app/services/flows/rental_flow.py index cf77f00..af5988d 100644 --- a/app/services/flows/rental_flow.py +++ b/app/services/flows/rental_flow.py @@ -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"(?.+)", + r"(?:tem|ha|existe|existem|mostre|mostrar|liste|listar|quais)\s+(?:um|uma|o|a)?\s*(?P.+)", + r"(?P.+?)\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, diff --git a/app/services/orchestration/orquestrador_service.py b/app/services/orchestration/orquestrador_service.py index 91135e4..4eb53ca 100644 --- a/app/services/orchestration/orquestrador_service.py +++ b/app/services/orchestration/orquestrador_service.py @@ -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, diff --git a/tests/test_conversation_adjustments.py b/tests/test_conversation_adjustments.py index 95c6ce3..66cb5ba 100644 --- a/tests/test_conversation_adjustments.py +++ b/tests/test_conversation_adjustments.py @@ -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={ diff --git a/tests/test_rental_service.py b/tests/test_rental_service.py index 9249457..a72dbb3 100644 --- a/tests/test_rental_service.py +++ b/tests/test_rental_service.py @@ -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() diff --git a/tests/test_turn_decision_contract.py b/tests/test_turn_decision_contract.py index 6a034ab..3a87e8f 100644 --- a/tests/test_turn_decision_contract.py +++ b/tests/test_turn_decision_contract.py @@ -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", + }, } } )