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 # 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) -> None: self._rental_flow_state_support.store_pending_rental_selection( user_id=user_id, rental_results=rental_results, ) # 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) # 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"(? 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 = ( r"\b\d{1,2}[/-]\d{1,2}[/-]\d{4}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?\b", r"\b\d{4}[/-]\d{1,2}[/-]\d{1,2}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?\b", ) results: list[str] = [] for pattern in patterns: for match in re.finditer(pattern, normalized): candidate = self._normalize_rental_datetime_text(match.group(0)) if candidate and candidate not in results: results.append(candidate) return results # 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: return None 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) # 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._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 if draft is None: draft = { "payload": {}, "expires_at": utc_now() + timedelta(minutes=PENDING_RENTAL_DRAFT_TTL_MINUTES), } draft_payload = draft.get("payload", {}) if not isinstance(draft_payload, dict): draft_payload = {} draft["payload"] = draft_payload draft_payload.update(extracted) self._try_capture_rental_fields_from_message(message=message, payload=draft_payload) selected_vehicle = self._get_selected_rental_vehicle(user_id=user_id) if selected_vehicle and not draft_payload.get("rental_vehicle_id"): draft_payload.update(self._rental_vehicle_to_payload(selected_vehicle)) resolved_vehicle = self._try_resolve_rental_vehicle( message=message, user_id=user_id, payload=draft_payload, ) if resolved_vehicle: self._store_selected_rental_vehicle(user_id=user_id, vehicle=resolved_vehicle) draft_payload.update(self._rental_vehicle_to_payload(resolved_vehicle)) draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_RENTAL_DRAFT_TTL_MINUTES) self.state.set_entry("pending_rental_drafts", user_id, draft) self._mark_rental_flow_active(user_id=user_id, active_task="rental_create") missing = [field for field in RENTAL_REQUIRED_FIELDS if field not in draft_payload] if missing: if "rental_vehicle_id" in missing: fleet_response = await self._try_list_rental_fleet_for_selection( message=message, user_id=user_id, payload=draft_payload, turn_decision=turn_decision, force=bool(draft) or explicit_rental_request or rental_listing_request or should_bootstrap_from_context, ) if fleet_response: return fleet_response rental_results = self._get_last_rental_results(user_id=user_id) if rental_results: return self._render_rental_selection_from_fleet_prompt(rental_results) return self._render_missing_rental_fields_prompt(missing) try: tool_result = await self.tool_executor.execute( "abrir_locacao_aluguel", { "rental_vehicle_id": draft_payload["rental_vehicle_id"], "placa": draft_payload.get("placa"), "data_inicio": draft_payload["data_inicio"], "data_fim_prevista": draft_payload["data_fim_prevista"], "cpf": draft_payload.get("cpf"), }, user_id=user_id, ) except HTTPException as exc: return self._http_exception_detail(exc) self._store_last_rental_contract(user_id=user_id, payload=tool_result) self._reset_pending_rental_states(user_id=user_id) 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)