🚧 feat(rental): estruturar fluxo multimodal de aluguel no Telegram

- adiciona frota, contratos e eventos de aluguel ao banco mock, ao seed operacional e ao bootstrap para habilitar o dominio de locacao de ponta a ponta no ambiente local

- cria o rental_service e o rental_flow com listagem da frota, selecao guiada por numero/placa/modelo, abertura e devolucao de contratos e continuidade incremental no orquestrador

- integra o processamento multimodal no Telegram para comprovantes e multas de aluguel, amplia o estado conversacional com contexto de locacao e fixa a resposta deterministica da listagem para permitir escolha apos a consulta

- adiciona cobertura para servico, seed, separacao entre compra e locacao, follow-ups do fluxo, resumo de contexto e cenarios multimodais do Telegram

# Conflicts:
#	app/db/mock_seed.py
#	app/services/orchestration/orchestrator_config.py
#	tests/test_conversation_adjustments.py
main
parent 876b0dd2d1
commit 0ba1660c20

@ -7,7 +7,17 @@ from app.core.settings import settings
from app.db.database import Base, engine from app.db.database import Base, engine
from app.db.mock_database import MockBase, mock_engine from app.db.mock_database import MockBase, mock_engine
from app.db.models import Tool from app.db.models import Tool
from app.db.mock_models import ConversationTurn, Customer, Order, ReviewSchedule, Vehicle from app.db.mock_models import (
ConversationTurn,
Customer,
Order,
RentalContract,
RentalFine,
RentalPayment,
RentalVehicle,
ReviewSchedule,
Vehicle,
)
from app.db.mock_seed import seed_mock_data from app.db.mock_seed import seed_mock_data
from app.db.tool_seed import seed_tools from app.db.tool_seed import seed_tools

@ -81,6 +81,82 @@ class ReviewSchedule(MockBase):
created_at = Column(DateTime, server_default=func.current_timestamp()) created_at = Column(DateTime, server_default=func.current_timestamp())
class RentalVehicle(MockBase):
__tablename__ = "rental_vehicles"
id = Column(Integer, primary_key=True, index=True)
placa = Column(String(10), unique=True, nullable=False, index=True)
modelo = Column(String(120), nullable=False, index=True)
categoria = Column(String(50), nullable=False, index=True)
ano = Column(Integer, nullable=False, index=True)
valor_diaria = Column(Float, nullable=False, index=True)
status = Column(String(20), nullable=False, default="disponivel", index=True)
created_at = Column(DateTime, server_default=func.current_timestamp())
class RentalContract(MockBase):
__tablename__ = "rental_contracts"
id = Column(Integer, primary_key=True, index=True)
contrato_numero = Column(String(50), unique=True, nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
cpf = Column(String(11), ForeignKey("customers.cpf"), nullable=True, index=True)
rental_vehicle_id = Column(Integer, ForeignKey("rental_vehicles.id"), nullable=False, index=True)
placa = Column(String(10), nullable=False, index=True)
modelo_veiculo = Column(String(120), nullable=False)
categoria = Column(String(50), nullable=False, index=True)
data_inicio = Column(DateTime, nullable=False, index=True)
data_fim_prevista = Column(DateTime, nullable=False, index=True)
data_devolucao = Column(DateTime, nullable=True, index=True)
valor_diaria = Column(Float, nullable=False)
valor_previsto = Column(Float, nullable=False)
valor_final = Column(Float, nullable=True)
status = Column(String(20), nullable=False, default="ativa", index=True)
observacoes = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.current_timestamp())
updated_at = Column(
DateTime,
server_default=func.current_timestamp(),
onupdate=func.current_timestamp(),
)
class RentalPayment(MockBase):
__tablename__ = "rental_payments"
id = Column(Integer, primary_key=True, index=True)
protocolo = Column(String(50), unique=True, nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
rental_contract_id = Column(Integer, ForeignKey("rental_contracts.id"), nullable=True, index=True)
contrato_numero = Column(String(50), nullable=True, index=True)
placa = Column(String(10), nullable=True, index=True)
valor = Column(Float, nullable=False)
data_pagamento = Column(DateTime, nullable=True, index=True)
favorecido = Column(String(120), nullable=True)
identificador_comprovante = Column(String(120), nullable=True, index=True)
observacoes = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.current_timestamp())
class RentalFine(MockBase):
__tablename__ = "rental_fines"
id = Column(Integer, primary_key=True, index=True)
protocolo = Column(String(50), unique=True, nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
rental_contract_id = Column(Integer, ForeignKey("rental_contracts.id"), nullable=True, index=True)
contrato_numero = Column(String(50), nullable=True, index=True)
placa = Column(String(10), nullable=True, index=True)
auto_infracao = Column(String(60), nullable=True, index=True)
orgao_emissor = Column(String(120), nullable=True)
valor = Column(Float, nullable=False)
data_infracao = Column(DateTime, nullable=True, index=True)
vencimento = Column(DateTime, nullable=True, index=True)
status = Column(String(20), nullable=False, default="registrada", index=True)
observacoes = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.current_timestamp())
class ConversationTurn(MockBase): class ConversationTurn(MockBase):
__tablename__ = "conversation_turns" __tablename__ = "conversation_turns"

@ -3,7 +3,7 @@ from datetime import datetime
from app.core.settings import settings from app.core.settings import settings
from app.db.mock_database import SessionMockLocal from app.db.mock_database import SessionMockLocal
from app.db.mock_models import Customer, Order, Vehicle from app.db.mock_models import Customer, Order, RentalVehicle, Vehicle
VEHICLE_MODELS = [ VEHICLE_MODELS = [
@ -59,6 +59,33 @@ VEHICLE_PRICE_BANDS = (
141990, 141990,
) )
RENTAL_FLEET = [
("RAA1A01", "Chevrolet Tracker", "suv", 2024, 219.90, "disponivel"),
("RAA1A02", "Fiat Pulse", "suv", 2024, 189.90, "disponivel"),
("RAA1A03", "Volkswagen Nivus", "suv", 2024, 209.90, "disponivel"),
("RAA1A04", "Hyundai Creta", "suv", 2024, 239.90, "disponivel"),
("RAA1A05", "Jeep Renegade", "suv", 2023, 249.90, "disponivel"),
("RAA1A06", "Honda HR-V", "suv", 2024, 269.90, "disponivel"),
("RAA1A07", "Toyota Corolla Cross", "suv", 2024, 289.90, "disponivel"),
("RAA1A08", "Fiat Toro", "pickup", 2024, 319.90, "disponivel"),
("RAA1A09", "Chevrolet S10", "pickup", 2023, 369.90, "manutencao"),
("RAA1A10", "Toyota Hilux", "pickup", 2023, 429.90, "disponivel"),
("RAA1A11", "Ram Rampage", "pickup", 2024, 459.90, "disponivel"),
("RAA1A12", "Peugeot 208", "hatch", 2024, 149.90, "disponivel"),
("RAA1A13", "Renault Kwid", "hatch", 2024, 119.90, "disponivel"),
("RAA1A14", "Volkswagen Polo", "hatch", 2024, 159.90, "disponivel"),
("RAA1A15", "BYD Dolphin", "hatch", 2024, 279.90, "disponivel"),
("RAA1A16", "Toyota Yaris", "sedan", 2023, 179.90, "disponivel"),
("RAA1A17", "Honda City", "sedan", 2024, 199.90, "disponivel"),
("RAA1A18", "Nissan Versa", "sedan", 2024, 189.90, "disponivel"),
("RAA1A19", "Chevrolet Onix Plus", "sedan", 2024, 169.90, "alugado"),
("RAA1A20", "Fiat Fastback", "suv", 2024, 229.90, "disponivel"),
("RAA1A21", "Caoa Chery Tiggo 5X", "suv", 2024, 219.90, "disponivel"),
("RAA1A22", "Hyundai HB20S", "sedan", 2024, 164.90, "disponivel"),
("RAA1A23", "Jeep Compass", "suv", 2023, 309.90, "alugado"),
("RAA1A24", "Ford Maverick", "pickup", 2024, 399.90, "disponivel"),
]
def _cpf_check_digit(base_digits: str) -> str: def _cpf_check_digit(base_digits: str) -> str:
total = sum(int(digit) * weight for digit, weight in zip(base_digits, range(len(base_digits) + 1, 1, -1))) total = sum(int(digit) * weight for digit, weight in zip(base_digits, range(len(base_digits) + 1, 1, -1)))
@ -145,6 +172,23 @@ def seed_mock_data() -> None:
db.add_all(vehicles) db.add_all(vehicles)
db.commit() db.commit()
existing_rental_plates = {placa for (placa,) in db.query(RentalVehicle.placa).all()}
missing_rental_vehicles = [
RentalVehicle(
placa=plate,
modelo=model,
categoria=category,
ano=year,
valor_diaria=daily_rate,
status=status,
)
for plate, model, category, year, daily_rate, status in RENTAL_FLEET
if plate not in existing_rental_plates
]
if missing_rental_vehicles:
db.add_all(missing_rental_vehicles)
db.commit()
customer_count = db.query(Customer).count() customer_count = db.query(Customer).count()
if customer_count < TARGET_CUSTOMER_COUNT: if customer_count < TARGET_CUSTOMER_COUNT:
customers = _seed_customer_records( customers = _seed_customer_records(
@ -164,4 +208,3 @@ def seed_mock_data() -> None:
db.commit() db.commit()
finally: finally:
db.close() db.close()

@ -285,6 +285,207 @@ def get_tools_definitions():
"required": ["numero_pedido", "motivo"], "required": ["numero_pedido", "motivo"],
}, },
}, },
{
"name": "consultar_frota_aluguel",
"description": (
"Use esta ferramenta quando o cliente quiser consultar carros disponiveis para locacao. "
"Ela retorna a frota de aluguel separada da frota de venda e permite filtrar por "
"categoria, modelo, valor maximo da diaria e status."
),
"parameters": {
"type": "object",
"properties": {
"categoria": {
"type": "string",
"description": "Categoria do veiculo para locacao, por exemplo hatch, sedan, suv ou pickup.",
},
"modelo": {
"type": "string",
"description": "Trecho do nome do modelo desejado para locacao. Opcional.",
},
"valor_diaria_max": {
"type": "number",
"description": "Valor maximo da diaria em reais para filtrar a frota.",
},
"status": {
"type": "string",
"description": "Status da frota para filtrar. Por padrao, consulte veiculos disponiveis.",
},
"ordenar_diaria": {
"type": "string",
"description": "Ordenacao por diaria. Use 'asc' para menor diaria e 'desc' para maior diaria.",
},
"limite": {
"type": "integer",
"description": "Quantidade maxima de veiculos retornados. Opcional.",
},
},
"required": [],
},
},
{
"name": "abrir_locacao_aluguel",
"description": (
"Use esta ferramenta quando o cliente quiser iniciar uma locacao de carro. "
"Ela recebe a placa ou o identificador do veiculo da frota de aluguel, data inicial, "
"data final prevista e opcionalmente CPF do cliente. Quando a locacao e aberta, o sistema "
"gera um contrato e marca o veiculo como alugado."
),
"parameters": {
"type": "object",
"properties": {
"rental_vehicle_id": {
"type": "integer",
"description": "Identificador do veiculo na frota de aluguel. Opcional se a placa for informada.",
},
"placa": {
"type": "string",
"description": "Placa do veiculo da frota de aluguel. Opcional se o identificador for informado.",
},
"data_inicio": {
"type": "string",
"description": "Data e hora de inicio da locacao. Aceita formatos como 17/03/2026 e 17/03/2026 10:00.",
},
"data_fim_prevista": {
"type": "string",
"description": "Data e hora previstas para devolucao da locacao.",
},
"cpf": {
"type": "string",
"description": "CPF do cliente, com ou sem formatacao. Opcional.",
},
"nome_cliente": {
"type": "string",
"description": "Nome do cliente para referencia textual. Opcional.",
},
"observacoes": {
"type": "string",
"description": "Observacoes adicionais sobre a locacao. Opcional.",
},
},
"required": ["data_inicio", "data_fim_prevista"],
},
},
{
"name": "registrar_devolucao_aluguel",
"description": (
"Use esta ferramenta quando o cliente informar a devolucao de um carro alugado. "
"Ela identifica a locacao pelo numero do contrato ou pela placa, fecha o contrato e "
"deixa o veiculo disponivel novamente para a frota de aluguel."
),
"parameters": {
"type": "object",
"properties": {
"contrato_numero": {
"type": "string",
"description": "Numero do contrato de locacao. Opcional se a placa for informada.",
},
"placa": {
"type": "string",
"description": "Placa do veiculo alugado. Opcional se o contrato for informado.",
},
"data_devolucao": {
"type": "string",
"description": "Data e hora da devolucao. Opcional; se nao vier, o sistema usa o horario atual.",
},
"observacoes": {
"type": "string",
"description": "Observacoes adicionais sobre a devolucao. Opcional.",
},
},
"required": [],
},
},
{
"name": "registrar_pagamento_aluguel",
"description": (
"Use esta ferramenta quando o usuario enviar um comprovante de pagamento "
"de aluguel ou pedir para registrar que o aluguel foi pago. Ela registra "
"o pagamento usando placa ou numero do contrato, valor, data do pagamento "
"e dados auxiliares do comprovante."
),
"parameters": {
"type": "object",
"properties": {
"contrato_numero": {
"type": "string",
"description": "Numero do contrato de aluguel, quando disponivel.",
},
"placa": {
"type": "string",
"description": "Placa do veiculo alugado, quando disponivel.",
},
"valor": {
"type": "number",
"description": "Valor pago no comprovante de aluguel.",
},
"data_pagamento": {
"type": "string",
"description": "Data do pagamento. Aceita formatos como 17/03/2026 e 2026-03-17 14:30.",
},
"favorecido": {
"type": "string",
"description": "Nome do recebedor ou favorecido, quando aparecer no comprovante.",
},
"identificador_comprovante": {
"type": "string",
"description": "Codigo, autenticacao, NSU ou identificador do comprovante.",
},
"observacoes": {
"type": "string",
"description": "Resumo livre do que foi identificado na imagem.",
},
},
"required": ["valor"],
},
},
{
"name": "registrar_multa_aluguel",
"description": (
"Use esta ferramenta quando o usuario enviar uma multa de transito de um "
"carro alugado ou pedir para registrar a multa no sistema. Ela registra "
"os dados da autuacao como placa, contrato, auto de infracao, orgao emissor, "
"valor, data da infracao e vencimento."
),
"parameters": {
"type": "object",
"properties": {
"placa": {
"type": "string",
"description": "Placa do veiculo relacionado a multa, quando disponivel.",
},
"contrato_numero": {
"type": "string",
"description": "Numero do contrato de aluguel, quando aparecer no documento.",
},
"auto_infracao": {
"type": "string",
"description": "Numero do auto de infracao ou identificador da multa.",
},
"orgao_emissor": {
"type": "string",
"description": "Orgao emissor da multa, por exemplo DETRAN ou prefeitura.",
},
"valor": {
"type": "number",
"description": "Valor da multa.",
},
"data_infracao": {
"type": "string",
"description": "Data da infracao. Aceita formatos como 17/03/2026 e 2026-03-17.",
},
"vencimento": {
"type": "string",
"description": "Data de vencimento da multa. Aceita formatos como 17/03/2026 e 2026-03-17.",
},
"observacoes": {
"type": "string",
"description": "Resumo livre do que foi identificado no documento.",
},
},
"required": ["valor"],
},
},
{ {
"name": "limpar_contexto_conversa", "name": "limpar_contexto_conversa",
"description": ( "description": (

@ -123,6 +123,7 @@ class TelegramSatelliteService:
def __init__(self, token: str): def __init__(self, token: str):
"""Configura cliente Telegram com URL base e timeouts padrao.""" """Configura cliente Telegram com URL base e timeouts padrao."""
self.base_url = f"https://api.telegram.org/bot{token}" self.base_url = f"https://api.telegram.org/bot{token}"
self.file_base_url = f"https://api.telegram.org/file/bot{token}"
self.polling_timeout = settings.telegram_polling_timeout self.polling_timeout = settings.telegram_polling_timeout
self.request_timeout = settings.telegram_request_timeout self.request_timeout = settings.telegram_request_timeout
self._last_update_id = -1 self._last_update_id = -1
@ -212,16 +213,23 @@ class TelegramSatelliteService:
) -> None: ) -> None:
"""Processa uma atualizacao recebida e envia resposta ao chat.""" """Processa uma atualizacao recebida e envia resposta ao chat."""
message = update.get("message", {}) message = update.get("message", {})
text = message.get("text") text = message.get("text") or message.get("caption")
chat = message.get("chat", {}) chat = message.get("chat", {})
chat_id = chat.get("id") chat_id = chat.get("id")
sender = message.get("from", {}) sender = message.get("from", {})
if not text or not chat_id: image_attachments = await self._extract_image_attachments(session=session, message=message)
if (not text and not image_attachments) or not chat_id:
return return
try: try:
answer = await self._process_message(text=text, sender=sender, chat_id=chat_id) answer = await self._process_message(
text=text or "",
sender=sender,
chat_id=chat_id,
image_attachments=image_attachments,
)
except HTTPException as exc: except HTTPException as exc:
logger.warning("Falha de dominio ao processar mensagem no Telegram: %s", exc.detail) logger.warning("Falha de dominio ao processar mensagem no Telegram: %s", exc.detail)
answer = str(exc.detail) if exc.detail else "Nao foi possivel concluir a operacao solicitada." answer = str(exc.detail) if exc.detail else "Nao foi possivel concluir a operacao solicitada."
@ -248,7 +256,13 @@ class TelegramSatelliteService:
if not data.get("ok"): if not data.get("ok"):
logger.warning("Falha em sendMessage: %s", data) logger.warning("Falha em sendMessage: %s", data)
async def _process_message(self, text: str, sender: Dict[str, Any], chat_id: int) -> str: async def _process_message(
self,
text: str,
sender: Dict[str, Any],
chat_id: int,
image_attachments: List[Dict[str, Any]] | None = None,
) -> str:
"""Encaminha mensagem ao orquestrador com usuario identificado e retorna resposta.""" """Encaminha mensagem ao orquestrador com usuario identificado e retorna resposta."""
tools_db = SessionLocal() tools_db = SessionLocal()
mock_db = SessionMockLocal() mock_db = SessionMockLocal()
@ -267,13 +281,125 @@ class TelegramSatelliteService:
username=username, username=username,
) )
message_text = text
if image_attachments:
image_message = await self._build_orchestration_message_from_image(
caption=text,
image_attachments=image_attachments,
)
if self._is_image_analysis_failure_message(image_message):
return image_message
message_text = image_message
service = OrquestradorService(tools_db) service = OrquestradorService(tools_db)
return await service.handle_message(message=text, user_id=user.id) return await service.handle_message(message=message_text, user_id=user.id)
finally: finally:
tools_db.close() tools_db.close()
mock_db.close() mock_db.close()
def _is_supported_image_document(self, document: Dict[str, Any]) -> bool:
mime_type = str((document or {}).get("mime_type") or "").strip().lower()
return mime_type.startswith("image/")
def _is_image_analysis_failure_message(self, text: str) -> bool:
normalized = str(text or "").strip().lower()
return normalized.startswith("nao consegui identificar os dados da imagem")
async def _extract_image_attachments(
self,
session: aiohttp.ClientSession,
message: Dict[str, Any],
) -> List[Dict[str, Any]]:
attachments: List[Dict[str, Any]] = []
photos = message.get("photo") or []
if isinstance(photos, list) and photos:
best_photo = max(
(item for item in photos if isinstance(item, dict) and item.get("file_id")),
key=lambda item: int(item.get("file_size") or 0),
default=None,
)
if best_photo is not None:
attachment = await self._download_image_attachment(
session=session,
file_id=str(best_photo.get("file_id")),
mime_type="image/jpeg",
file_name="telegram_photo.jpg",
)
if attachment:
attachments.append(attachment)
document = message.get("document") or {}
if isinstance(document, dict) and document.get("file_id") and self._is_supported_image_document(document):
attachment = await self._download_image_attachment(
session=session,
file_id=str(document.get("file_id")),
mime_type=str(document.get("mime_type") or "image/jpeg"),
file_name=str(document.get("file_name") or "telegram_image"),
)
if attachment:
attachments.append(attachment)
return attachments
async def _download_image_attachment(
self,
session: aiohttp.ClientSession,
file_id: str,
mime_type: str,
file_name: str,
) -> Dict[str, Any] | None:
async with session.post(f"{self.base_url}/getFile", json={"file_id": file_id}) as response:
data = await response.json()
if not data.get("ok"):
logger.warning("Falha em getFile para imagem do Telegram: %s", data)
return None
file_path = str((data.get("result") or {}).get("file_path") or "").strip()
if not file_path:
return None
async with session.get(f"{self.file_base_url}/{file_path}") as file_response:
if file_response.status >= 400:
logger.warning("Falha ao baixar arquivo do Telegram: status=%s path=%s", file_response.status, file_path)
return None
payload = await file_response.read()
if not payload:
return None
return {
"mime_type": mime_type,
"file_name": file_name,
"data": payload,
}
async def _build_orchestration_message_from_image(
self,
*,
caption: str,
image_attachments: List[Dict[str, Any]],
) -> str:
extracted_message = await LLMService().extract_image_workflow_message(
caption=caption,
attachments=image_attachments,
)
caption_text = str(caption or "").strip()
extracted_text = str(extracted_message or "").strip()
if self._is_image_analysis_failure_message(extracted_text):
return extracted_text
if caption_text and extracted_text:
return (
"[imagem recebida no telegram]\n"
f"Legenda do usuario: {caption_text}\n"
f"Dados extraidos da imagem: {extracted_text}"
)
if extracted_text:
return f"[imagem recebida no telegram]\nDados extraidos da imagem: {extracted_text}"
if caption_text:
return caption_text
return "Recebi uma imagem, mas preciso que voce descreva o documento ou envie uma foto mais nitida."
async def main() -> None: async def main() -> None:
"""Inicializa servico satelite do Telegram e inicia processamento continuo.""" """Inicializa servico satelite do Telegram e inicia processamento continuo."""
token = settings.telegram_bot_token token = settings.telegram_bot_token

@ -4,7 +4,7 @@ from typing import Dict, Any, List, Optional
import vertexai import vertexai
from google.api_core.exceptions import NotFound from google.api_core.exceptions import NotFound
from vertexai.generative_models import FunctionDeclaration, GenerativeModel, Tool from vertexai.generative_models import FunctionDeclaration, GenerativeModel, Part, Tool
from app.core.settings import settings from app.core.settings import settings
from app.models.tool_model import ToolDefinition from app.models.tool_model import ToolDefinition
@ -30,6 +30,62 @@ class LLMService:
fallback_models = ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash-001"] fallback_models = ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash-001"]
self.model_names = [configured] + [m for m in fallback_models if m != configured] self.model_names = [configured] + [m for m in fallback_models if m != configured]
async def extract_image_workflow_message(
self,
*,
caption: str | None,
attachments: List[Dict[str, Any]],
) -> str:
"""Analisa imagem(ns) e devolve uma mensagem textual pronta para o orquestrador."""
if not attachments:
return str(caption or "").strip()
prompt = (
"Voce esta preparando uma mensagem textual curta para um orquestrador de atendimento automotivo e locacao. "
"Analise a imagem enviada pelo usuario e a legenda opcional. "
"Se for comprovante de pagamento de aluguel, responda com uma frase objetiva em portugues no formato: "
"Registrar pagamento de aluguel: contrato <...>; placa <...>; valor <...>; data_pagamento <...>; favorecido <...>; identificador_comprovante <...>; observacoes <...>. "
"Se for multa de transito relacionada a carro alugado, responda com uma frase objetiva em portugues no formato: "
"Registrar multa de aluguel: placa <...>; contrato <...>; auto_infracao <...>; orgao_emissor <...>; valor <...>; data_infracao <...>; vencimento <...>; observacoes <...>. "
"Se for outro documento automotivo util, resuma em uma frase com os dados importantes. "
"Se nao conseguir identificar com seguranca, responda exatamente: Nao consegui identificar os dados da imagem. Descreva o documento ou envie uma foto mais nitida. "
"Use apenas dados observaveis e nao invente informacoes. "
f"Legenda do usuario: {(caption or '').strip() or 'sem legenda'}"
)
contents: List[Any] = [prompt]
for attachment in attachments:
raw_data = attachment.get("data")
mime_type = str(attachment.get("mime_type") or "image/jpeg").strip() or "image/jpeg"
if not isinstance(raw_data, (bytes, bytearray)) or not raw_data:
continue
contents.append(Part.from_data(data=bytes(raw_data), mime_type=mime_type))
if len(contents) == 1:
return "Nao consegui identificar os dados da imagem. Descreva o documento ou envie uma foto mais nitida."
response = None
last_error = None
for model_name in self.model_names:
try:
model = self._get_model(model_name)
response = await asyncio.to_thread(model.generate_content, contents)
break
except NotFound as err:
last_error = err
LLMService._models.pop(model_name, None)
continue
if response is None:
if last_error:
raise RuntimeError(
f"Nenhum modelo Vertex disponivel para analise de imagem. Erro: {last_error}"
) from last_error
raise RuntimeError("Falha ao analisar imagem no Vertex AI.")
payload = self._extract_response_payload(response)
return (payload.get("response") or "").strip() or (caption or "").strip()
def build_vertex_tools(self, tools: List[ToolDefinition]) -> Optional[List[Tool]]: def build_vertex_tools(self, tools: List[ToolDefinition]) -> Optional[List[Tool]]:
"""Converte tools internas para o formato esperado pelo Vertex AI.""" """Converte tools internas para o formato esperado pelo Vertex AI."""
# Vertex espera uma lista de Tool, com function_declarations agrupadas em um unico Tool. # Vertex espera uma lista de Tool, com function_declarations agrupadas em um unico Tool.

@ -0,0 +1,562 @@
import math
import re
from datetime import datetime
from typing import Any
from uuid import uuid4
from app.core.time_utils import utc_now
from app.db.mock_database import SessionMockLocal
from app.db.mock_models import RentalContract, RentalFine, RentalPayment, RentalVehicle, User
from app.services.domain.tool_errors import raise_tool_http_error
from app.services.orchestration import technical_normalizer
from app.services.user.mock_customer_service import hydrate_mock_customer_from_cpf
def _normalize_contract_number(value: str | None) -> str | None:
text = str(value or "").strip().upper()
return text or None
def _normalize_text_field(value: str | None) -> str | None:
text = str(value or "").strip(" ,.;")
return text or None
def _parse_optional_datetime(value: str | None, *, field_name: str) -> datetime | None:
text = str(value or "").strip()
if not text:
return None
normalized = re.sub(r"\s+(?:as|às)\s+", " ", text, flags=re.IGNORECASE)
for candidate in (text, normalized):
try:
return datetime.fromisoformat(candidate.replace("Z", "+00:00"))
except ValueError:
pass
for fmt in (
"%d/%m/%Y %H:%M",
"%d/%m/%Y",
"%Y-%m-%d %H:%M",
"%Y-%m-%d",
):
try:
return datetime.strptime(normalized, fmt)
except ValueError:
continue
raise_tool_http_error(
status_code=400,
code="invalid_rental_datetime",
message=(
f"{field_name} invalido. Exemplos aceitos: "
"17/03/2026, 17/03/2026 14:30, 2026-03-17, 2026-03-17 14:30."
),
retryable=True,
field=field_name,
)
return None
def _parse_required_datetime(value: str | None, *, field_name: str) -> datetime:
parsed = _parse_optional_datetime(value, field_name=field_name)
if parsed is None:
raise_tool_http_error(
status_code=400,
code="missing_rental_datetime",
message=f"Informe {field_name} para continuar a locacao.",
retryable=True,
field=field_name,
)
return parsed
def _normalize_money(value) -> float:
number = technical_normalizer.normalize_positive_number(value)
if number is None or float(number) <= 0:
raise_tool_http_error(
status_code=400,
code="invalid_amount",
message="Informe um valor monetario valido maior que zero.",
retryable=True,
field="valor",
)
return float(number)
def _normalize_vehicle_id(value) -> int | None:
if value is None or value == "":
return None
try:
numeric = int(value)
except (TypeError, ValueError):
raise_tool_http_error(
status_code=400,
code="invalid_rental_vehicle_id",
message="Informe um identificador numerico de veiculo para locacao.",
retryable=True,
field="rental_vehicle_id",
)
if numeric <= 0:
raise_tool_http_error(
status_code=400,
code="invalid_rental_vehicle_id",
message="Informe um identificador numerico de veiculo para locacao.",
retryable=True,
field="rental_vehicle_id",
)
return numeric
def _calculate_rental_days(start: datetime, end: datetime) -> int:
delta_seconds = (end - start).total_seconds()
if delta_seconds < 0:
raise_tool_http_error(
status_code=400,
code="invalid_rental_period",
message="A data final da locacao nao pode ser anterior a data inicial.",
retryable=True,
field="data_fim_prevista",
)
if delta_seconds == 0:
return 1
return max(1, math.ceil(delta_seconds / 86400))
def _lookup_rental_vehicle(
db,
*,
rental_vehicle_id: int | None = None,
placa: str | None = None,
) -> RentalVehicle | None:
if rental_vehicle_id is not None:
return db.query(RentalVehicle).filter(RentalVehicle.id == rental_vehicle_id).first()
normalized_plate = technical_normalizer.normalize_plate(placa)
if normalized_plate:
return db.query(RentalVehicle).filter(RentalVehicle.placa == normalized_plate).first()
raise_tool_http_error(
status_code=400,
code="missing_rental_vehicle_reference",
message="Informe a placa ou o identificador do veiculo de locacao.",
retryable=True,
field="placa",
)
return None
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()
own_contract = query.filter(RentalContract.user_id == user_id).order_by(RentalContract.created_at.desc()).first()
if own_contract is not None:
return own_contract
return query.filter(RentalContract.user_id.is_(None)).order_by(RentalContract.created_at.desc()).first()
def _resolve_rental_contract(
db,
*,
contrato_numero: str | None = None,
placa: str | None = None,
user_id: int | None = None,
active_only: bool = False,
) -> RentalContract | None:
normalized_contract = _normalize_contract_number(contrato_numero)
normalized_plate = technical_normalizer.normalize_plate(placa)
if normalized_contract:
query = db.query(RentalContract).filter(RentalContract.contrato_numero == normalized_contract)
if active_only:
query = query.filter(RentalContract.status == "ativa")
contract = _lookup_contract_by_user_preference(query, user_id)
if contract is not None:
return contract
if normalized_plate:
query = db.query(RentalContract).filter(RentalContract.placa == normalized_plate)
if active_only:
query = query.filter(RentalContract.status == "ativa")
contract = _lookup_contract_by_user_preference(query, user_id)
if contract is not None:
return contract
if user_id is not None:
query = db.query(RentalContract).filter(RentalContract.user_id == user_id)
if active_only:
query = query.filter(RentalContract.status == "ativa")
contracts = query.order_by(RentalContract.created_at.desc()).limit(2).all()
if len(contracts) == 1:
return contracts[0]
return None
async def consultar_frota_aluguel(
categoria: str | None = None,
valor_diaria_max: float | None = None,
modelo: str | None = None,
status: str | None = None,
ordenar_diaria: str | None = None,
limite: int | None = None,
) -> list[dict[str, Any]]:
db = SessionMockLocal()
try:
query = db.query(RentalVehicle)
normalized_status = str(status or "").strip().lower() or "disponivel"
query = query.filter(RentalVehicle.status == normalized_status)
if categoria:
query = query.filter(RentalVehicle.categoria == str(categoria).strip().lower())
if valor_diaria_max is not None:
max_value = technical_normalizer.normalize_positive_number(valor_diaria_max)
if max_value is not None:
query = query.filter(RentalVehicle.valor_diaria <= float(max_value))
if modelo:
query = query.filter(RentalVehicle.modelo.ilike(f"%{str(modelo).strip()}%"))
if ordenar_diaria in {"asc", "desc"}:
query = query.order_by(
RentalVehicle.valor_diaria.asc()
if ordenar_diaria == "asc"
else RentalVehicle.valor_diaria.desc()
)
else:
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
rows = query.all()
return [
{
"id": row.id,
"placa": row.placa,
"modelo": row.modelo,
"categoria": row.categoria,
"ano": int(row.ano),
"valor_diaria": float(row.valor_diaria),
"status": row.status,
}
for row in rows
]
finally:
db.close()
async def abrir_locacao_aluguel(
data_inicio: str,
data_fim_prevista: str,
rental_vehicle_id: int | None = None,
placa: str | None = None,
cpf: str | None = None,
nome_cliente: str | None = None,
observacoes: str | None = None,
user_id: int | None = None,
) -> dict[str, Any]:
vehicle_id = _normalize_vehicle_id(rental_vehicle_id)
data_inicio_dt = _parse_required_datetime(data_inicio, field_name="data_inicio")
data_fim_dt = _parse_required_datetime(data_fim_prevista, field_name="data_fim_prevista")
diarias = _calculate_rental_days(data_inicio_dt, data_fim_dt)
cpf_norm = technical_normalizer.normalize_cpf(cpf)
if cpf and not cpf_norm:
raise_tool_http_error(
status_code=400,
code="invalid_cpf",
message="Informe um CPF valido para a locacao ou remova esse campo.",
retryable=True,
field="cpf",
)
if cpf_norm:
await hydrate_mock_customer_from_cpf(cpf=cpf_norm, user_id=user_id)
db = SessionMockLocal()
try:
vehicle = _lookup_rental_vehicle(db, rental_vehicle_id=vehicle_id, placa=placa)
if vehicle is None:
raise_tool_http_error(
status_code=404,
code="rental_vehicle_not_found",
message="Veiculo de aluguel nao encontrado.",
retryable=True,
field="placa",
)
if vehicle.status != "disponivel":
raise_tool_http_error(
status_code=409,
code="rental_vehicle_unavailable",
message=(
f"O veiculo {vehicle.placa} nao esta disponivel para locacao no momento. "
f"Status atual: {vehicle.status}."
),
retryable=True,
field="placa",
)
contract_number = f"LOC-{utc_now().strftime('%Y%m%d')}-{uuid4().hex[:8].upper()}"
total_preview = round(float(vehicle.valor_diaria) * diarias, 2)
contract = RentalContract(
contrato_numero=contract_number,
user_id=user_id,
cpf=cpf_norm,
rental_vehicle_id=vehicle.id,
placa=vehicle.placa,
modelo_veiculo=vehicle.modelo,
categoria=vehicle.categoria,
data_inicio=data_inicio_dt,
data_fim_prevista=data_fim_dt,
valor_diaria=float(vehicle.valor_diaria),
valor_previsto=total_preview,
status="ativa",
observacoes=_normalize_text_field(observacoes),
)
if user_id is not None and cpf_norm:
user = db.query(User).filter(User.id == user_id).first()
if user and user.cpf != cpf_norm:
user.cpf = cpf_norm
vehicle.status = "alugado"
db.add(contract)
db.commit()
db.refresh(contract)
db.refresh(vehicle)
return {
"contrato_numero": contract.contrato_numero,
"placa": contract.placa,
"modelo_veiculo": contract.modelo_veiculo,
"categoria": contract.categoria,
"data_inicio": contract.data_inicio.isoformat(),
"data_fim_prevista": contract.data_fim_prevista.isoformat(),
"valor_diaria": float(contract.valor_diaria),
"valor_previsto": float(contract.valor_previsto),
"status": contract.status,
"status_veiculo": vehicle.status,
"cpf": contract.cpf,
"nome_cliente": _normalize_text_field(nome_cliente),
}
finally:
db.close()
async def registrar_devolucao_aluguel(
contrato_numero: str | None = None,
placa: str | None = None,
data_devolucao: str | None = None,
observacoes: str | None = None,
user_id: int | None = None,
) -> dict[str, Any]:
db = SessionMockLocal()
try:
contract = _resolve_rental_contract(
db,
contrato_numero=contrato_numero,
placa=placa,
user_id=user_id,
active_only=True,
)
if contract is None:
raise_tool_http_error(
status_code=404,
code="rental_contract_not_found",
message="Nao encontrei uma locacao ativa com os dados informados.",
retryable=True,
field="contrato_numero",
)
returned_at = _parse_optional_datetime(data_devolucao, field_name="data_devolucao") or utc_now()
if returned_at < contract.data_inicio:
raise_tool_http_error(
status_code=400,
code="invalid_return_datetime",
message="A data de devolucao nao pode ser anterior ao inicio da locacao.",
retryable=True,
field="data_devolucao",
)
rental_days = _calculate_rental_days(contract.data_inicio, returned_at)
contract.data_devolucao = returned_at
contract.valor_final = round(float(contract.valor_diaria) * rental_days, 2)
contract.status = "encerrada"
contract.observacoes = _normalize_text_field(observacoes) or contract.observacoes
vehicle = db.query(RentalVehicle).filter(RentalVehicle.id == contract.rental_vehicle_id).first()
if vehicle is not None:
vehicle.status = "disponivel"
db.commit()
db.refresh(contract)
if vehicle is not None:
db.refresh(vehicle)
return {
"contrato_numero": contract.contrato_numero,
"placa": contract.placa,
"modelo_veiculo": contract.modelo_veiculo,
"data_devolucao": contract.data_devolucao.isoformat() if contract.data_devolucao else None,
"valor_previsto": float(contract.valor_previsto),
"valor_final": float(contract.valor_final) if contract.valor_final is not None else None,
"status": contract.status,
"status_veiculo": vehicle.status if vehicle is not None else None,
}
finally:
db.close()
async def registrar_pagamento_aluguel(
valor: float,
contrato_numero: str | None = None,
placa: str | None = None,
data_pagamento: str | None = None,
favorecido: str | None = None,
identificador_comprovante: str | None = None,
observacoes: str | None = None,
user_id: int | None = None,
) -> dict:
contract = None
normalized_contract = _normalize_contract_number(contrato_numero)
plate = technical_normalizer.normalize_plate(placa)
db = SessionMockLocal()
try:
contract = _resolve_rental_contract(
db,
contrato_numero=normalized_contract,
placa=plate,
user_id=user_id,
active_only=False,
)
if contract is not None:
normalized_contract = contract.contrato_numero
plate = contract.placa
if not normalized_contract and not plate:
raise_tool_http_error(
status_code=400,
code="missing_rental_reference",
message=(
"Preciso da placa, do numero do contrato ou de uma locacao ativa vinculada ao usuario "
"para registrar o pagamento do aluguel."
),
retryable=True,
field="placa",
)
record = RentalPayment(
protocolo=f"ALP-{utc_now().strftime('%Y%m%d')}-{uuid4().hex[:8].upper()}",
user_id=user_id,
rental_contract_id=contract.id if contract is not None else None,
contrato_numero=normalized_contract,
placa=plate,
valor=_normalize_money(valor),
data_pagamento=_parse_optional_datetime(data_pagamento, field_name="data_pagamento"),
favorecido=_normalize_text_field(favorecido),
identificador_comprovante=_normalize_text_field(identificador_comprovante),
observacoes=_normalize_text_field(observacoes),
)
db.add(record)
db.commit()
db.refresh(record)
return {
"protocolo": record.protocolo,
"rental_contract_id": record.rental_contract_id,
"contrato_numero": record.contrato_numero,
"placa": record.placa,
"valor": float(record.valor),
"data_pagamento": record.data_pagamento.isoformat() if record.data_pagamento else None,
"favorecido": record.favorecido,
"identificador_comprovante": record.identificador_comprovante,
"status": "registrado",
}
finally:
db.close()
async def registrar_multa_aluguel(
valor: float,
placa: str | None = None,
contrato_numero: str | None = None,
auto_infracao: str | None = None,
orgao_emissor: str | None = None,
data_infracao: str | None = None,
vencimento: str | None = None,
observacoes: str | None = None,
user_id: int | None = None,
) -> dict:
normalized_contract = _normalize_contract_number(contrato_numero)
plate = technical_normalizer.normalize_plate(placa)
notice_number = _normalize_text_field(auto_infracao)
db = SessionMockLocal()
try:
contract = _resolve_rental_contract(
db,
contrato_numero=normalized_contract,
placa=plate,
user_id=user_id,
active_only=False,
)
if contract is not None:
normalized_contract = contract.contrato_numero
plate = contract.placa
if not normalized_contract and not plate and not notice_number:
raise_tool_http_error(
status_code=400,
code="missing_fine_reference",
message=(
"Preciso da placa, do numero do contrato, do auto da infracao ou de uma locacao "
"vinculada ao usuario para registrar a multa."
),
retryable=True,
field="placa",
)
record = RentalFine(
protocolo=f"ALM-{utc_now().strftime('%Y%m%d')}-{uuid4().hex[:8].upper()}",
user_id=user_id,
rental_contract_id=contract.id if contract is not None else None,
contrato_numero=normalized_contract,
placa=plate,
auto_infracao=notice_number,
orgao_emissor=_normalize_text_field(orgao_emissor),
valor=_normalize_money(valor),
data_infracao=_parse_optional_datetime(data_infracao, field_name="data_infracao"),
vencimento=_parse_optional_datetime(vencimento, field_name="vencimento"),
observacoes=_normalize_text_field(observacoes),
status="registrada",
)
db.add(record)
db.commit()
db.refresh(record)
return {
"protocolo": record.protocolo,
"rental_contract_id": record.rental_contract_id,
"contrato_numero": record.contrato_numero,
"placa": record.placa,
"auto_infracao": record.auto_infracao,
"orgao_emissor": record.orgao_emissor,
"valor": float(record.valor),
"data_infracao": record.data_infracao.isoformat() if record.data_infracao else None,
"vencimento": record.vencimento.isoformat() if record.vencimento else None,
"status": record.status,
}
finally:
db.close()

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

@ -66,6 +66,10 @@ CONTEXT_FIELD_LABELS = {
"modelo_veiculo": "modelo_veiculo", "modelo_veiculo": "modelo_veiculo",
"valor_veiculo": "valor_veiculo", "valor_veiculo": "valor_veiculo",
"numero_pedido": "numero_pedido", "numero_pedido": "numero_pedido",
"rental_vehicle_id": "rental_vehicle_id",
"data_inicio": "data de inicio",
"data_fim_prevista": "data fim prevista",
"valor_diaria_max": "valor maximo da diaria",
} }
ACTIVE_TASK_LABELS = { ACTIVE_TASK_LABELS = {
@ -73,6 +77,7 @@ ACTIVE_TASK_LABELS = {
"review_management": "gestao de revisao", "review_management": "gestao de revisao",
"order_create": "criacao de pedido", "order_create": "criacao de pedido",
"order_cancel": "cancelamento de pedido", "order_cancel": "cancelamento de pedido",
"rental_create": "abertura de locacao",
} }
# essa classe é responsável por controlar qual o assunto está ativo na conversa, se existe fluxo aberto, se o usuário mandou dois pedidos ao mesmo tempo... # essa classe é responsável por controlar qual o assunto está ativo na conversa, se existe fluxo aberto, se o usuário mandou dois pedidos ao mesmo tempo...
@ -322,6 +327,7 @@ class ConversationPolicy:
domain_prefix = { domain_prefix = {
"review": "Revisao", "review": "Revisao",
"sales": "Venda", "sales": "Venda",
"rental": "Locacao",
"general": "Atendimento", "general": "Atendimento",
}.get(domain, "Atendimento") }.get(domain, "Atendimento")
return f"{domain_prefix}: {message}" return f"{domain_prefix}: {message}"
@ -396,7 +402,7 @@ class ConversationPolicy:
def looks_like_fresh_operational_request(self, message: str, turn_decision: dict | None = None) -> bool: def looks_like_fresh_operational_request(self, message: str, turn_decision: dict | None = None) -> bool:
decision_domain = self._decision_domain(turn_decision) decision_domain = self._decision_domain(turn_decision)
decision_intent = self._decision_intent(turn_decision) decision_intent = self._decision_intent(turn_decision)
if decision_domain in {"review", "sales"} or decision_intent not in {"", "general"}: if decision_domain in {"review", "sales", "rental"} or decision_intent not in {"", "general"}:
return True return True
return self.looks_like_fresh_operational_request_from_text(message) return self.looks_like_fresh_operational_request_from_text(message)
@ -415,6 +421,11 @@ class ConversationPolicy:
"veiculo", "veiculo",
"remarcar", "remarcar",
"tambem", "tambem",
"aluguel",
"alugar",
"locacao",
"locar",
"devolver",
} }
return self.contains_any_term(normalized, operational_terms) return self.contains_any_term(normalized, operational_terms)
@ -710,7 +721,7 @@ class ConversationPolicy:
def is_context_switch_confirmation(self, message: str, turn_decision: dict | None = None) -> bool: def is_context_switch_confirmation(self, message: str, turn_decision: dict | None = None) -> bool:
if self._decision_action(turn_decision) in {"continue_queue", "cancel_active_flow", "clear_context", "discard_queue"}: if self._decision_action(turn_decision) in {"continue_queue", "cancel_active_flow", "clear_context", "discard_queue"}:
return True return True
if self._decision_domain(turn_decision) in {"review", "sales"}: if self._decision_domain(turn_decision) in {"review", "sales", "rental"}:
return True return True
return self.service._is_affirmative_message(message) or self.service._is_negative_message(message) return self.service._is_affirmative_message(message) or self.service._is_negative_message(message)
@ -801,6 +812,11 @@ class ConversationPolicy:
self.service.state.get_entry("pending_order_drafts", user_id, expire=True) self.service.state.get_entry("pending_order_drafts", user_id, expire=True)
or self.service.state.get_entry("pending_cancel_order_drafts", user_id, expire=True) or self.service.state.get_entry("pending_cancel_order_drafts", user_id, expire=True)
) )
if domain == "rental":
return bool(
self.service.state.get_entry("pending_rental_drafts", user_id, expire=True)
or self.service.state.get_entry("pending_rental_selections", user_id, expire=True)
)
return False return False
@ -814,6 +830,8 @@ class ConversationPolicy:
self.service._reset_pending_review_states(user_id=user_id) self.service._reset_pending_review_states(user_id=user_id)
if previous_domain == "sales": if previous_domain == "sales":
self.service._reset_pending_order_states(user_id=user_id) self.service._reset_pending_order_states(user_id=user_id)
if previous_domain == "rental":
self.service._reset_pending_rental_states(user_id=user_id)
context["active_domain"] = target_domain context["active_domain"] = target_domain
context["generic_memory"] = self.service._new_tab_memory(user_id=user_id) context["generic_memory"] = self.service._new_tab_memory(user_id=user_id)
context["pending_order_selection"] = None context["pending_order_selection"] = None
@ -838,7 +856,7 @@ class ConversationPolicy:
context["pending_switch"] = None context["pending_switch"] = None
self._save_context(user_id=user_id, context=context) self._save_context(user_id=user_id, context=context)
elif ( elif (
self._decision_domain(turn_decision) in {"review", "sales"} self._decision_domain(turn_decision) in {"review", "sales", "rental"}
and self._decision_domain(turn_decision) != pending_switch["target_domain"] and self._decision_domain(turn_decision) != pending_switch["target_domain"]
): ):
context["pending_switch"] = None context["pending_switch"] = None
@ -890,6 +908,7 @@ class ConversationPolicy:
labels = { labels = {
"review": "agendamento de revisao", "review": "agendamento de revisao",
"sales": "compra de veiculo", "sales": "compra de veiculo",
"rental": "locacao de veiculo",
"general": "atendimento geral", "general": "atendimento geral",
} }
return labels.get(domain, "atendimento") return labels.get(domain, "atendimento")
@ -909,6 +928,8 @@ class ConversationPolicy:
return "Pode me dizer a faixa de preco, o modelo ou o tipo de carro que voce procura." return "Pode me dizer a faixa de preco, o modelo ou o tipo de carro que voce procura."
if target_domain == "review": if target_domain == "review":
return "Pode me informar a placa ou, se preferir, ja mandar placa, data/hora, modelo, ano, km e se ja fez revisao." return "Pode me informar a placa ou, se preferir, ja mandar placa, data/hora, modelo, ano, km e se ja fez revisao."
if target_domain == "rental":
return "Pode me dizer qual carro voce quer alugar e o periodo desejado para a locacao."
return "Pode me dizer o que voce quer fazer agora?" return "Pode me dizer o que voce quer fazer agora?"
def render_context_switched_message(self, target_domain: str) -> str: def render_context_switched_message(self, target_domain: str) -> str:
@ -1008,9 +1029,15 @@ class ConversationPolicy:
selected_vehicle = context.get("selected_vehicle") selected_vehicle = context.get("selected_vehicle")
if isinstance(selected_vehicle, dict) and selected_vehicle.get("modelo"): if isinstance(selected_vehicle, dict) and selected_vehicle.get("modelo"):
summary.append(f"Veiculo selecionado para compra: {selected_vehicle.get('modelo')}.") summary.append(f"Veiculo selecionado para compra: {selected_vehicle.get('modelo')}.")
selected_rental_vehicle = context.get("selected_rental_vehicle")
if isinstance(selected_rental_vehicle, dict) and selected_rental_vehicle.get("modelo"):
summary.append(f"Veiculo selecionado para locacao: {selected_rental_vehicle.get('modelo')}.")
stock_results = context.get("last_stock_results") or [] stock_results = context.get("last_stock_results") or []
if isinstance(stock_results, list) and stock_results: if isinstance(stock_results, list) and stock_results:
summary.append(f"Ultima consulta de estoque com {len(stock_results)} opcao(oes) disponivel(is).") summary.append(f"Ultima consulta de estoque com {len(stock_results)} opcao(oes) disponivel(is).")
rental_results = context.get("last_rental_results") or []
if isinstance(rental_results, list) and rental_results:
summary.append(f"Ultima consulta de locacao com {len(rental_results)} opcao(oes) disponivel(is).")
last_tool_result = context.get("last_tool_result") last_tool_result = context.get("last_tool_result")
if isinstance(last_tool_result, dict) and last_tool_result.get("tool_name"): if isinstance(last_tool_result, dict) and last_tool_result.get("tool_name"):
tool_name = str(last_tool_result.get("tool_name") or "").strip() tool_name = str(last_tool_result.get("tool_name") or "").strip()
@ -1125,4 +1152,22 @@ class ConversationPolicy:
payload = stock_selection.get("payload") payload = stock_selection.get("payload")
if isinstance(payload, list) and payload: if isinstance(payload, list) and payload:
summary.append(f"Aguardando escolha de veiculo em {len(payload)} opcao(oes) de estoque.") summary.append(f"Aguardando escolha de veiculo em {len(payload)} opcao(oes) de estoque.")
rental_draft = self._get_pending_entry(user_id, "pending_rental_drafts")
if isinstance(rental_draft, dict):
payload = rental_draft.get("payload")
known_fields = self._summarize_payload(payload, ("placa", "rental_vehicle_id", "data_inicio", "data_fim_prevista", "cpf"))
missing_fields = self._summarize_missing_fields(payload, ("rental_vehicle_id", "data_inicio", "data_fim_prevista"))
draft_summary = "Rascunho aberto de locacao."
if known_fields:
draft_summary = f"{draft_summary} Dados atuais: {known_fields}."
if missing_fields:
draft_summary = f"{draft_summary} Faltando: {missing_fields}."
summary.append(draft_summary)
rental_selection = self._get_pending_entry(user_id, "pending_rental_selections")
if isinstance(rental_selection, dict):
payload = rental_selection.get("payload")
if isinstance(payload, list) and payload:
summary.append(f"Aguardando escolha de veiculo em {len(payload)} opcao(oes) de locacao.")
return " ".join(summary) return " ".join(summary)

@ -17,6 +17,8 @@ class ConversationStateStore(ConversationStateRepository):
self.pending_order_drafts: dict[int, dict] = {} self.pending_order_drafts: dict[int, dict] = {}
self.pending_cancel_order_drafts: dict[int, dict] = {} self.pending_cancel_order_drafts: dict[int, dict] = {}
self.pending_stock_selections: dict[int, dict] = {} self.pending_stock_selections: dict[int, dict] = {}
self.pending_rental_drafts: dict[int, dict] = {}
self.pending_rental_selections: dict[int, dict] = {}
def upsert_user_context(self, user_id: int | None, ttl_minutes: int) -> None: def upsert_user_context(self, user_id: int | None, ttl_minutes: int) -> None:
if user_id is None: if user_id is None:
@ -39,6 +41,8 @@ class ConversationStateStore(ConversationStateRepository):
"pending_switch": None, "pending_switch": None,
"last_stock_results": [], "last_stock_results": [],
"selected_vehicle": None, "selected_vehicle": None,
"last_rental_results": [],
"selected_rental_vehicle": None,
"expires_at": now + timedelta(minutes=ttl_minutes), "expires_at": now + timedelta(minutes=ttl_minutes),
} }

@ -1,13 +1,15 @@
# Constantes compartilhadas do orquestrador: # Constantes compartilhadas do orquestrador:
# TTLs, campos obrigatorios, respostas de baixo valor e tools especiais. # TTLs, campos obrigatorios, respostas de baixo valor e tools especiais.
USER_CONTEXT_TTL_MINUTES = 60 USER_CONTEXT_TTL_MINUTES = 60
PENDING_ORDER_SELECTION_TTL_MINUTES = 15 PENDING_ORDER_SELECTION_TTL_MINUTES = 15
PENDING_RENTAL_SELECTION_TTL_MINUTES = 15
PENDING_REVIEW_TTL_MINUTES = 30 PENDING_REVIEW_TTL_MINUTES = 30
PENDING_REVIEW_DRAFT_TTL_MINUTES = 30 PENDING_REVIEW_DRAFT_TTL_MINUTES = 30
LAST_REVIEW_PACKAGE_TTL_MINUTES = 20 LAST_REVIEW_PACKAGE_TTL_MINUTES = 20
PENDING_ORDER_DRAFT_TTL_MINUTES = 30 PENDING_ORDER_DRAFT_TTL_MINUTES = 30
PENDING_CANCEL_ORDER_DRAFT_TTL_MINUTES = 30 PENDING_CANCEL_ORDER_DRAFT_TTL_MINUTES = 30
PENDING_RENTAL_DRAFT_TTL_MINUTES = 30
REVIEW_REQUIRED_FIELDS = ( REVIEW_REQUIRED_FIELDS = (
"placa", "placa",
@ -23,6 +25,12 @@ ORDER_REQUIRED_FIELDS = (
"vehicle_id", "vehicle_id",
) )
RENTAL_REQUIRED_FIELDS = (
"rental_vehicle_id",
"data_inicio",
"data_fim_prevista",
)
CANCEL_ORDER_REQUIRED_FIELDS = ( CANCEL_ORDER_REQUIRED_FIELDS = (
"numero_pedido", "numero_pedido",
"motivo", "motivo",
@ -42,11 +50,16 @@ LOW_VALUE_RESPONSES = {
DETERMINISTIC_RESPONSE_TOOLS = { DETERMINISTIC_RESPONSE_TOOLS = {
"consultar_estoque", "consultar_estoque",
"avaliar_veiculo_troca", "avaliar_veiculo_troca",
"consultar_frota_aluguel",
"cancelar_pedido", "cancelar_pedido",
"listar_pedidos", "listar_pedidos",
"listar_agendamentos_revisao", "listar_agendamentos_revisao",
"cancelar_agendamento_revisao", "cancelar_agendamento_revisao",
"editar_data_revisao", "editar_data_revisao",
"abrir_locacao_aluguel",
"registrar_devolucao_aluguel",
"registrar_pagamento_aluguel",
"registrar_multa_aluguel",
"limpar_contexto_conversa", "limpar_contexto_conversa",
"continuar_proximo_pedido", "continuar_proximo_pedido",
"descartar_pedidos_pendentes", "descartar_pedidos_pendentes",
@ -59,3 +72,6 @@ ORCHESTRATION_CONTROL_TOOLS = {
"descartar_pedidos_pendentes", "descartar_pedidos_pendentes",
"cancelar_fluxo_atual", "cancelar_fluxo_atual",
} }

@ -23,6 +23,7 @@ from app.services.orchestration.message_planner import MessagePlanner
from app.services.orchestration.conversation_history_service import ConversationHistoryService from app.services.orchestration.conversation_history_service import ConversationHistoryService
from app.services.orchestration.state_repository_factory import get_conversation_state_repository from app.services.orchestration.state_repository_factory import get_conversation_state_repository
from app.services.flows.order_flow import OrderFlowMixin from app.services.flows.order_flow import OrderFlowMixin
from app.services.flows.rental_flow import RentalFlowMixin
from app.services.orchestration.prompt_builders import ( from app.services.orchestration.prompt_builders import (
build_force_tool_prompt, build_force_tool_prompt,
build_result_prompt, build_result_prompt,
@ -37,7 +38,7 @@ logger = logging.getLogger(__name__)
# Coordenador principal do turno conversacional: # Coordenador principal do turno conversacional:
# atualiza estado, pede decisoes ao modelo, continua fluxos e executa tools. # atualiza estado, pede decisoes ao modelo, continua fluxos e executa tools.
class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
def __init__( def __init__(
self, self,
db: Session, db: Session,
@ -130,6 +131,20 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
) )
if active_sales_follow_up: if active_sales_follow_up:
return active_sales_follow_up return active_sales_follow_up
pending_rental_selection_follow_up = await self._try_handle_pending_rental_selection_follow_up(
message=message,
user_id=user_id,
finish=finish,
)
if pending_rental_selection_follow_up:
return pending_rental_selection_follow_up
active_rental_follow_up = await self._try_handle_active_rental_follow_up(
message=message,
user_id=user_id,
finish=finish,
)
if active_rental_follow_up:
return active_rental_follow_up
active_review_follow_up = await self._try_handle_active_review_follow_up( active_review_follow_up = await self._try_handle_active_review_follow_up(
message=message, message=message,
user_id=user_id, user_id=user_id,
@ -261,6 +276,11 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
turn_decision=turn_decision, turn_decision=turn_decision,
user_id=user_id, user_id=user_id,
) )
should_prioritize_rental_flow = self._should_prioritize_rental_flow(
turn_decision=turn_decision,
user_id=user_id,
message=routing_message,
)
domain_hint = self._domain_from_turn_decision(turn_decision) domain_hint = self._domain_from_turn_decision(turn_decision)
if domain_hint == "general": if domain_hint == "general":
domain_hint = self._domain_from_intents(extracted_entities.get("intents", {})) domain_hint = self._domain_from_intents(extracted_entities.get("intents", {}))
@ -272,6 +292,8 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
extracted_entities=extracted_entities, extracted_entities=extracted_entities,
): ):
domain_hint = "sales" domain_hint = "sales"
elif self._has_rental_listing_request(routing_message, turn_decision=turn_decision) or self._has_explicit_rental_request(routing_message):
domain_hint = "rental"
context_switch_response = self._handle_context_switch( context_switch_response = self._handle_context_switch(
message=routing_message, message=routing_message,
user_id=user_id, user_id=user_id,
@ -311,6 +333,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
and not should_prioritize_review_flow and not should_prioritize_review_flow
and not should_prioritize_review_management and not should_prioritize_review_management
and not should_prioritize_order_flow and not should_prioritize_order_flow
and not should_prioritize_rental_flow
): ):
return await finish(decision_response, queue_notice=queue_notice) return await finish(decision_response, queue_notice=queue_notice)
if ( if (
@ -319,6 +342,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
and not should_prioritize_review_flow and not should_prioritize_review_flow
and not should_prioritize_review_management and not should_prioritize_review_management
and not should_prioritize_order_flow and not should_prioritize_order_flow
and not should_prioritize_rental_flow
): ):
return await finish(decision_response, queue_notice=queue_notice) return await finish(decision_response, queue_notice=queue_notice)
@ -391,6 +415,15 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
) )
if order_response: if order_response:
return await finish(order_response, queue_notice=queue_notice) return await finish(order_response, queue_notice=queue_notice)
rental_response = await self._try_collect_and_open_rental(
message=routing_message,
user_id=user_id,
extracted_fields={},
intents={},
turn_decision=turn_decision,
)
if rental_response:
return await finish(rental_response, queue_notice=queue_notice)
tools = self.registry.get_tools() tools = self.registry.get_tools()
@ -559,6 +592,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
and ( and (
self._has_open_flow(user_id=user_id, domain="review") self._has_open_flow(user_id=user_id, domain="review")
or self._has_open_flow(user_id=user_id, domain="sales") or self._has_open_flow(user_id=user_id, domain="sales")
or self._has_open_flow(user_id=user_id, domain="rental")
or bool((self._get_user_context(user_id) or {}).get("pending_switch")) or bool((self._get_user_context(user_id) or {}).get("pending_switch"))
or bool((self._get_user_context(user_id) or {}).get("order_queue")) or bool((self._get_user_context(user_id) or {}).get("order_queue"))
) )
@ -629,6 +663,84 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
return None return None
return await finish(response) return await finish(response)
async def _try_handle_pending_rental_selection_follow_up(
self,
message: str,
user_id: int | None,
finish,
) -> str | None:
if user_id is None:
return None
pending_selection = self.state.get_entry("pending_rental_selections", user_id, expire=True)
if not pending_selection:
return None
if not self._should_bootstrap_rental_from_context(
message=message,
user_id=user_id,
payload={},
):
return None
response = await self._try_collect_and_open_rental(
message=message,
user_id=user_id,
extracted_fields={},
intents={},
turn_decision={
"intent": "rental_create",
"domain": "rental",
"action": "collect_rental_create",
},
)
if not response:
return None
return await finish(response)
async def _try_handle_active_rental_follow_up(
self,
message: str,
user_id: int | None,
finish,
) -> str | None:
if user_id is None:
return None
context = self._get_user_context(user_id)
if not isinstance(context, dict):
return None
if str(context.get("active_domain") or "").strip().lower() != "rental":
return None
normalized_message = self.normalizer.normalize_text(message).strip()
if self._looks_like_explicit_domain_shift_request(normalized_message):
return None
if (
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_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,
extracted_fields={},
intents={},
turn_decision={
"intent": "rental_create",
"domain": "rental",
"action": "collect_rental_create",
},
)
if response:
return await finish(response)
return None
async def _try_handle_active_sales_follow_up( async def _try_handle_active_sales_follow_up(
self, self,
message: str, message: str,
@ -852,6 +964,21 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
queue_notice=queue_notice, queue_notice=queue_notice,
) )
def _reset_pending_rental_states(self, user_id: int | None) -> None:
if user_id is None:
return
self.state.pop_entry("pending_rental_drafts", user_id)
self.state.pop_entry("pending_rental_selections", user_id)
context = self._get_user_context(user_id)
if isinstance(context, dict):
context["last_rental_results"] = []
context["selected_rental_vehicle"] = None
if context.get("active_task") == "rental_create":
context["active_task"] = None
if str(context.get("active_domain") or "").strip().lower() == "rental":
context["active_domain"] = "general"
self._save_user_context(user_id=user_id, context=context)
def _reset_pending_review_states(self, user_id: int | None) -> None: def _reset_pending_review_states(self, user_id: int | None) -> None:
if user_id is None: if user_id is None:
return return
@ -901,6 +1028,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
return return
self._reset_pending_review_states(user_id=user_id) self._reset_pending_review_states(user_id=user_id)
self._reset_pending_order_states(user_id=user_id) self._reset_pending_order_states(user_id=user_id)
self._reset_pending_rental_states(user_id=user_id)
self.state.pop_entry("last_review_packages", user_id) self.state.pop_entry("last_review_packages", user_id)
context["active_domain"] = "general" context["active_domain"] = "general"
context["active_task"] = None context["active_task"] = None
@ -914,6 +1042,8 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
context["pending_switch"] = None context["pending_switch"] = None
context["last_stock_results"] = [] context["last_stock_results"] = []
context["selected_vehicle"] = None context["selected_vehicle"] = None
context["last_rental_results"] = []
context["selected_rental_vehicle"] = None
self._save_user_context(user_id=user_id, context=context) self._save_user_context(user_id=user_id, context=context)
def _clear_pending_order_navigation(self, user_id: int | None) -> int: def _clear_pending_order_navigation(self, user_id: int | None) -> int:
@ -943,6 +1073,8 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
self._reset_pending_review_states(user_id=user_id) self._reset_pending_review_states(user_id=user_id)
elif active_domain == "sales": elif active_domain == "sales":
self._reset_pending_order_states(user_id=user_id) self._reset_pending_order_states(user_id=user_id)
elif active_domain == "rental":
self._reset_pending_rental_states(user_id=user_id)
context["pending_switch"] = None context["pending_switch"] = None
self._save_user_context(user_id=user_id, context=context) self._save_user_context(user_id=user_id, context=context)
@ -1091,6 +1223,16 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
"tool_name": tool_name, "tool_name": tool_name,
"result_type": type(tool_result).__name__, "result_type": type(tool_result).__name__,
} }
if tool_name == "consultar_frota_aluguel" and isinstance(tool_result, list):
sanitized_rental = self._sanitize_rental_results(tool_result[:20])
context["last_rental_results"] = sanitized_rental
self._store_pending_rental_selection(user_id=user_id, rental_results=sanitized_rental)
if sanitized_rental:
context["selected_rental_vehicle"] = None
context["active_domain"] = "rental"
self._save_user_context(user_id=user_id, context=context)
return
if tool_name != "consultar_estoque" or not isinstance(tool_result, list): if tool_name != "consultar_estoque" or not isinstance(tool_result, list):
self._save_user_context(user_id=user_id, context=context) self._save_user_context(user_id=user_id, context=context)
return return
@ -1353,7 +1495,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
def _domain_from_turn_decision(self, turn_decision: dict | None) -> str: def _domain_from_turn_decision(self, turn_decision: dict | None) -> str:
domain = str((turn_decision or {}).get("domain") or "general").strip().lower() domain = str((turn_decision or {}).get("domain") or "general").strip().lower()
if domain in {"review", "sales", "general"}: if domain in {"review", "sales", "rental", "general"}:
return domain return domain
return "general" return "general"
@ -1442,6 +1584,9 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
): ):
return False return False
if self.state.get_entry("pending_rental_drafts", user_id, expire=True) or self.state.get_entry("pending_rental_selections", user_id, expire=True):
return False
entities = extracted_entities if isinstance(extracted_entities, dict) else {} entities = extracted_entities if isinstance(extracted_entities, dict) else {}
order_fields = entities.get("order_fields") order_fields = entities.get("order_fields")
if not isinstance(order_fields, dict): if not isinstance(order_fields, dict):
@ -1470,9 +1615,44 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
"agendamento", "agendamento",
"cancelar revisao", "cancelar revisao",
"remarcar revisao", "remarcar revisao",
"alugar",
"aluguel",
"locacao",
"locar",
"devolver",
) )
return any(term in normalized_message for term in shift_terms) return any(term in normalized_message for term in shift_terms)
def _should_prioritize_rental_flow(
self,
turn_decision: dict | None,
user_id: int | None = None,
message: str | None = None,
) -> bool:
has_open_rental_draft = bool(
user_id is not None
and (
self.state.get_entry("pending_rental_drafts", user_id, expire=True)
or self.state.get_entry("pending_rental_selections", user_id, expire=True)
)
)
if has_open_rental_draft:
return True
active_domain = str(((self._get_user_context(user_id) or {}) if user_id is not None else {}).get("active_domain") or "").strip().lower()
if active_domain == "rental":
return True
if message and user_id is not None and self._should_bootstrap_rental_from_context(message=message, user_id=user_id, payload={}):
return True
decision = turn_decision or {}
decision_intent = str(decision.get("intent") or "").strip().lower()
if decision_intent in {"rental_create", "rental_list", "rental_search"}:
return True
return bool(message and (self._has_rental_listing_request(message, turn_decision=turn_decision) or self._has_explicit_rental_request(message)))
def _should_prioritize_review_management( def _should_prioritize_review_management(
self, self,
turn_decision: dict | None, turn_decision: dict | None,

@ -44,6 +44,8 @@ class RedisConversationStateRepository(ConversationStateRepository):
"pending_switch": None, "pending_switch": None,
"last_stock_results": [], "last_stock_results": [],
"selected_vehicle": None, "selected_vehicle": None,
"last_rental_results": [],
"selected_rental_vehicle": None,
"expires_at": now, "expires_at": now,
} }

@ -164,6 +164,90 @@ def fallback_format_tool_result(tool_name: str, tool_result: Any) -> str:
f"Limite: {limite}" f"Limite: {limite}"
) )
if tool_name == "consultar_frota_aluguel" and isinstance(tool_result, list):
if not tool_result:
return "Nao encontrei veiculos disponiveis para locacao com os criterios informados."
linhas = [f"Encontrei {len(tool_result)} veiculo(s) para locacao:"]
for idx, item in enumerate(tool_result[:10], start=1):
modelo = item.get("modelo", "N/A")
placa = item.get("placa", "N/A")
categoria = item.get("categoria", "N/A")
ano = item.get("ano", "N/A")
diaria = format_currency_br(item.get("valor_diaria"))
linhas.append(f"{idx}. {modelo} {ano} | {placa} | {categoria} | diaria {diaria}")
restantes = len(tool_result) - 10
if restantes > 0:
linhas.append(f"... e mais {restantes} veiculo(s) para locacao.")
linhas.append("Se quiser seguir com a locacao de um deles, responda com o numero da lista, a placa ou o modelo.")
return "\n".join(linhas)
if tool_name == "abrir_locacao_aluguel" and isinstance(tool_result, dict):
contrato = tool_result.get("contrato_numero", "N/A")
placa = tool_result.get("placa", "N/A")
modelo = tool_result.get("modelo_veiculo", "N/A")
data_inicio = format_datetime_for_chat(tool_result.get("data_inicio", "N/A"))
data_fim = format_datetime_for_chat(tool_result.get("data_fim_prevista", "N/A"))
valor_diaria = format_currency_br(tool_result.get("valor_diaria"))
valor_previsto = format_currency_br(tool_result.get("valor_previsto"))
return (
"Locacao aberta com sucesso.\n"
f"Contrato: {contrato}\n"
f"Veiculo: {modelo}\n"
f"Placa: {placa}\n"
f"Inicio: {data_inicio}\n"
f"Devolucao prevista: {data_fim}\n"
f"Diaria: {valor_diaria}\n"
f"Valor previsto: {valor_previsto}"
)
if tool_name == "registrar_devolucao_aluguel" and isinstance(tool_result, dict):
contrato = tool_result.get("contrato_numero", "N/A")
placa = tool_result.get("placa", "N/A")
modelo = tool_result.get("modelo_veiculo", "N/A")
data_devolucao = format_datetime_for_chat(tool_result.get("data_devolucao", "N/A"))
valor_final = format_currency_br(tool_result.get("valor_final"))
return (
"Devolucao de aluguel registrada com sucesso.\n"
f"Contrato: {contrato}\n"
f"Veiculo: {modelo}\n"
f"Placa: {placa}\n"
f"Data da devolucao: {data_devolucao}\n"
f"Valor final estimado: {valor_final}"
)
if tool_name == "registrar_pagamento_aluguel" and isinstance(tool_result, dict):
protocolo = tool_result.get("protocolo", "N/A")
contrato = tool_result.get("contrato_numero") or "N/A"
placa = tool_result.get("placa") or "N/A"
valor = format_currency_br(tool_result.get("valor"))
data_pagamento = format_datetime_for_chat(tool_result.get("data_pagamento", "N/A"))
return (
"Pagamento de aluguel registrado com sucesso.\n"
f"Protocolo: {protocolo}\n"
f"Contrato: {contrato}\n"
f"Placa: {placa}\n"
f"Valor: {valor}\n"
f"Data do pagamento: {data_pagamento}"
)
if tool_name == "registrar_multa_aluguel" and isinstance(tool_result, dict):
protocolo = tool_result.get("protocolo", "N/A")
contrato = tool_result.get("contrato_numero") or "N/A"
placa = tool_result.get("placa") or "N/A"
valor = format_currency_br(tool_result.get("valor"))
data_infracao = format_datetime_for_chat(tool_result.get("data_infracao", "N/A"))
vencimento = format_datetime_for_chat(tool_result.get("vencimento", "N/A"))
auto_infracao = tool_result.get("auto_infracao") or "N/A"
return (
"Multa de aluguel registrada com sucesso.\n"
f"Protocolo: {protocolo}\n"
f"Contrato: {contrato}\n"
f"Placa: {placa}\n"
f"Auto de infracao: {auto_infracao}\n"
f"Valor: {valor}\n"
f"Data da infracao: {data_infracao}\n"
f"Vencimento: {vencimento}"
)
if tool_name in { if tool_name in {
"limpar_contexto_conversa", "limpar_contexto_conversa",
"continuar_proximo_pedido", "continuar_proximo_pedido",

@ -3,6 +3,13 @@ from typing import Any
from app.services.domain.credit_service import validar_cliente_venda from app.services.domain.credit_service import validar_cliente_venda
from app.services.domain.inventory_service import avaliar_veiculo_troca, consultar_estoque from app.services.domain.inventory_service import avaliar_veiculo_troca, consultar_estoque
from app.services.domain.order_service import cancelar_pedido, listar_pedidos, realizar_pedido from app.services.domain.order_service import cancelar_pedido, listar_pedidos, realizar_pedido
from app.services.domain.rental_service import (
abrir_locacao_aluguel,
consultar_frota_aluguel,
registrar_devolucao_aluguel,
registrar_multa_aluguel,
registrar_pagamento_aluguel,
)
from app.services.domain.review_service import ( from app.services.domain.review_service import (
agendar_revisao, agendar_revisao,
cancelar_agendamento_revisao, cancelar_agendamento_revisao,
@ -26,9 +33,14 @@ __all__ = [
"cancelar_agendamento_revisao", "cancelar_agendamento_revisao",
"cancelar_pedido", "cancelar_pedido",
"consultar_estoque", "consultar_estoque",
"consultar_frota_aluguel",
"editar_data_revisao", "editar_data_revisao",
"listar_agendamentos_revisao", "listar_agendamentos_revisao",
"listar_pedidos", "listar_pedidos",
"abrir_locacao_aluguel",
"registrar_devolucao_aluguel",
"registrar_multa_aluguel",
"registrar_pagamento_aluguel",
"realizar_pedido", "realizar_pedido",
"validar_cliente_venda", "validar_cliente_venda",
] ]

@ -15,6 +15,11 @@ from app.services.tools.handlers import (
listar_agendamentos_revisao, listar_agendamentos_revisao,
listar_pedidos, listar_pedidos,
consultar_estoque, consultar_estoque,
consultar_frota_aluguel,
abrir_locacao_aluguel,
registrar_devolucao_aluguel,
registrar_multa_aluguel,
registrar_pagamento_aluguel,
realizar_pedido, realizar_pedido,
validar_cliente_venda, validar_cliente_venda,
) )
@ -22,6 +27,7 @@ from app.services.tools.handlers import (
HANDLERS: Dict[str, Callable] = { HANDLERS: Dict[str, Callable] = {
"consultar_estoque": consultar_estoque, "consultar_estoque": consultar_estoque,
"consultar_frota_aluguel": consultar_frota_aluguel,
"validar_cliente_venda": validar_cliente_venda, "validar_cliente_venda": validar_cliente_venda,
"avaliar_veiculo_troca": avaliar_veiculo_troca, "avaliar_veiculo_troca": avaliar_veiculo_troca,
"agendar_revisao": agendar_revisao, "agendar_revisao": agendar_revisao,
@ -31,6 +37,10 @@ HANDLERS: Dict[str, Callable] = {
"cancelar_pedido": cancelar_pedido, "cancelar_pedido": cancelar_pedido,
"listar_pedidos": listar_pedidos, "listar_pedidos": listar_pedidos,
"realizar_pedido": realizar_pedido, "realizar_pedido": realizar_pedido,
"abrir_locacao_aluguel": abrir_locacao_aluguel,
"registrar_devolucao_aluguel": registrar_devolucao_aluguel,
"registrar_pagamento_aluguel": registrar_pagamento_aluguel,
"registrar_multa_aluguel": registrar_multa_aluguel,
} }

@ -200,6 +200,58 @@ class ContextSummaryTests(unittest.TestCase):
self.assertIn("Dados atuais: placa=ABC1C23, modelo=Onix, ano=2024.", summary) self.assertIn("Dados atuais: placa=ABC1C23, modelo=Onix, ano=2024.", summary)
self.assertIn("Faltando: data/hora, km, revisao previa na concessionaria.", summary) self.assertIn("Faltando: data/hora, km, revisao previa na concessionaria.", summary)
def test_build_context_summary_describes_open_rental_flow(self):
now = utc_now()
state = FakeState(
entries={
"pending_rental_drafts": {
5: {
"payload": {
"placa": "RAA1A01",
"rental_vehicle_id": 1,
"data_inicio": "20/03/2026 10:00",
},
"expires_at": now + timedelta(minutes=15),
}
},
"pending_rental_selections": {
5: {
"payload": [
{"id": 1, "placa": "RAA1A01", "modelo": "Chevrolet Tracker", "categoria": "suv", "ano": 2024, "valor_diaria": 219.9, "status": "disponivel"},
],
"expires_at": now + timedelta(minutes=15),
}
},
},
contexts={
5: {
"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": [
{"id": 1, "placa": "RAA1A01", "modelo": "Chevrolet Tracker", "categoria": "suv", "ano": 2024, "valor_diaria": 219.9, "status": "disponivel"},
],
"selected_rental_vehicle": {"id": 1, "placa": "RAA1A01", "modelo": "Chevrolet Tracker"},
"last_tool_result": {"tool_name": "consultar_frota_aluguel", "result_type": "list"},
}
},
)
summary = ConversationPolicy(service=FakeService(state)).build_context_summary(5)
self.assertIn("Fluxo ativo: abertura de locacao.", summary)
self.assertIn("Veiculo selecionado para locacao: Chevrolet Tracker.", summary)
self.assertIn("Ultima consulta de locacao com 1 opcao(oes) disponivel(is).", summary)
self.assertIn("Rascunho aberto de locacao.", summary)
self.assertIn("Faltando: data fim prevista.", summary)
self.assertIn("Aguardando escolha de veiculo em 1 opcao(oes) de locacao.", summary)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

@ -1,4 +1,4 @@
import os import os
import unittest import unittest
from datetime import datetime, timedelta from datetime import datetime, timedelta
from app.core.time_utils import utc_now from app.core.time_utils import utc_now
@ -9,6 +9,7 @@ os.environ.setdefault("DEBUG", "false")
from fastapi import HTTPException from fastapi import HTTPException
from app.services.flows.order_flow import OrderFlowMixin from app.services.flows.order_flow import OrderFlowMixin
from app.services.flows.rental_flow import RentalFlowMixin
from app.services.flows.review_flow import ReviewFlowMixin from app.services.flows.review_flow import ReviewFlowMixin
from app.integrations.telegram_satellite_service import _ensure_supported_runtime_configuration, _split_telegram_text from app.integrations.telegram_satellite_service import _ensure_supported_runtime_configuration, _split_telegram_text
from app.models.tool_model import ToolDefinition from app.models.tool_model import ToolDefinition
@ -84,6 +85,25 @@ class FakeRegistry:
] ]
reverse = str(arguments.get("ordenar_preco") or "asc").lower() == "desc" reverse = str(arguments.get("ordenar_preco") or "asc").lower() == "desc"
return sorted(stock_results, key=lambda item: float(item["preco"]), reverse=reverse) return sorted(stock_results, key=lambda item: float(item["preco"]), reverse=reverse)
if tool_name == "consultar_frota_aluguel":
return [
{"id": 1, "placa": "RAA1A01", "modelo": "Chevrolet Tracker", "categoria": "suv", "ano": 2024, "valor_diaria": 219.9, "status": "disponivel"},
{"id": 2, "placa": "RAA1A02", "modelo": "Fiat Pulse", "categoria": "suv", "ano": 2024, "valor_diaria": 189.9, "status": "disponivel"},
]
if tool_name == "abrir_locacao_aluguel":
return {
"contrato_numero": "LOC-TESTE-123",
"placa": arguments.get("placa") or "RAA1A01",
"modelo_veiculo": "Chevrolet Tracker",
"categoria": "suv",
"data_inicio": arguments["data_inicio"],
"data_fim_prevista": arguments["data_fim_prevista"],
"valor_diaria": 219.9,
"valor_previsto": 659.7,
"status": "ativa",
"status_veiculo": "alugado",
"cpf": arguments.get("cpf"),
}
if tool_name == "listar_pedidos": if tool_name == "listar_pedidos":
return [ return [
{ {
@ -240,6 +260,66 @@ class OrderFlowHarness(OrderFlowMixin):
return None return None
class RentalFlowHarness(RentalFlowMixin):
def __init__(self, state, registry):
self.state = state
self.registry = registry
self.tool_executor = registry
self.normalizer = EntityNormalizer()
def _get_user_context(self, user_id: int | None):
return self.state.get_user_context(user_id)
def _save_user_context(self, user_id: int | None, context: dict | None) -> None:
if user_id is None or not isinstance(context, dict):
return
self.state.save_user_context(user_id, context)
def _normalize_text(self, text: str) -> str:
return self.normalizer.normalize_text(text)
def _decision_intent(self, turn_decision: dict | None) -> str:
return str((turn_decision or {}).get("intent") or "").strip().lower()
def _http_exception_detail(self, exc) -> str:
return str(exc)
def _reset_pending_rental_states(self, user_id: int | None) -> None:
if user_id is None:
return
self.state.pop_entry("pending_rental_drafts", user_id)
self.state.pop_entry("pending_rental_selections", user_id)
context = self._get_user_context(user_id)
if isinstance(context, dict):
context["last_rental_results"] = []
context["selected_rental_vehicle"] = None
if context.get("active_task") == "rental_create":
context["active_task"] = None
if str(context.get("active_domain") or "").strip().lower() == "rental":
context["active_domain"] = "general"
self._save_user_context(user_id=user_id, context=context)
def _fallback_format_tool_result(self, tool_name: str, tool_result) -> str:
if tool_name == "consultar_frota_aluguel":
lines = [f"Encontrei {len(tool_result)} veiculo(s) para locacao:"]
for idx, item in enumerate(tool_result, start=1):
lines.append(
f"{idx}. {item['modelo']} {item['ano']} | {item['placa']} | "
f"{item['categoria']} | diaria R$ {item['valor_diaria']:.2f}"
)
lines.append("Para seguir com a locacao, informe a placa ou o numero da opcao desejada.")
return "\n".join(lines)
if tool_name == "abrir_locacao_aluguel":
return (
"Locacao aberta com sucesso.\n"
f"Contrato: {tool_result['contrato_numero']}\n"
f"Placa: {tool_result['placa']}\n"
f"Inicio: {tool_result['data_inicio']}\n"
f"Devolucao prevista: {tool_result['data_fim_prevista']}"
)
return str(tool_result)
class ReviewFlowHarness(ReviewFlowMixin): class ReviewFlowHarness(ReviewFlowMixin):
def __init__(self, state, registry, review_now_provider=None): def __init__(self, state, registry, review_now_provider=None):
self.state = state self.state = state
@ -1731,6 +1811,108 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
) )
class RentalFlowDraftTests(unittest.IsolatedAsyncioTestCase):
def _base_context(self):
return {
"active_domain": "general",
"active_task": None,
"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,
}
async def test_rental_flow_lists_fleet_and_stores_pending_selection(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 carros 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.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_accepts_vehicle_selection_from_list_index(self):
state = FakeState(
entries={
"pending_rental_selections": {
21: {
"payload": [
{"id": 1, "placa": "RAA1A01", "modelo": "Chevrolet Tracker", "categoria": "suv", "ano": 2024, "valor_diaria": 219.9, "status": "disponivel"},
{"id": 2, "placa": "RAA1A02", "modelo": "Fiat Pulse", "categoria": "suv", "ano": 2024, "valor_diaria": 189.9, "status": "disponivel"},
],
"expires_at": utc_now() + timedelta(minutes=15),
}
}
},
contexts={21: self._base_context() | {"active_domain": "rental"}},
)
registry = FakeRegistry()
flow = RentalFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_open_rental(
message="1",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "rental_create", "domain": "rental", "action": "answer_user"},
)
draft = state.get_entry("pending_rental_drafts", 21)
self.assertIsNotNone(draft)
self.assertEqual(draft["payload"]["rental_vehicle_id"], 1)
self.assertEqual(state.get_user_context(21)["selected_rental_vehicle"]["placa"], "RAA1A01")
self.assertIn("a data e hora de inicio da locacao", response)
self.assertIn("a data e hora previstas para devolucao", response)
async def test_rental_flow_opens_contract_after_collecting_dates(self):
state = FakeState(
entries={
"pending_rental_drafts": {
21: {
"payload": {
"rental_vehicle_id": 1,
"placa": "RAA1A01",
"modelo_veiculo": "Chevrolet Tracker",
},
"expires_at": utc_now() + timedelta(minutes=15),
}
}
},
contexts={21: self._base_context() | {"active_domain": "rental", "selected_rental_vehicle": {"id": 1, "placa": "RAA1A01", "modelo": "Chevrolet Tracker", "categoria": "suv", "ano": 2024, "valor_diaria": 219.9, "status": "disponivel"}}},
)
registry = FakeRegistry()
flow = RentalFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_open_rental(
message="20/03/2026 10:00 ate 23/03/2026 10:00",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "rental_create", "domain": "rental", "action": "answer_user"},
)
self.assertEqual(registry.calls[0][0], "abrir_locacao_aluguel")
self.assertEqual(registry.calls[0][1]["rental_vehicle_id"], 1)
self.assertEqual(registry.calls[0][1]["data_inicio"], "20/03/2026 10:00")
self.assertEqual(registry.calls[0][1]["data_fim_prevista"], "23/03/2026 10:00")
self.assertIsNone(state.get_entry("pending_rental_drafts", 21))
self.assertIn("LOC-TESTE-123", response)
class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase): class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
async def test_review_flow_extracts_relative_datetime_from_followup_message(self): async def test_review_flow_extracts_relative_datetime_from_followup_message(self):
fixed_now = lambda: datetime(2026, 3, 12, 9, 0) fixed_now = lambda: datetime(2026, 3, 12, 9, 0)
@ -2920,3 +3102,6 @@ class ToolRegistryExecutionTests(unittest.IsolatedAsyncioTestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

@ -0,0 +1,65 @@
import unittest
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.db.mock_database import MockBase
from app.db.mock_models import Order, RentalVehicle, Vehicle
from app.services.domain.inventory_service import consultar_estoque
class InventoryServiceIsolationTests(unittest.IsolatedAsyncioTestCase):
def _build_session_local(self):
engine = create_engine("sqlite:///:memory:")
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
MockBase.metadata.create_all(bind=engine)
self.addCleanup(engine.dispose)
return SessionLocal
async def test_consultar_estoque_uses_only_sales_fleet(self):
SessionLocal = self._build_session_local()
db = SessionLocal()
try:
reserved_sale_vehicle = Vehicle(
modelo="Toyota Corolla 2024",
categoria="sedan",
preco=76087.0,
)
available_sale_vehicle = Vehicle(
modelo="Renault Duster 2022",
categoria="suv",
preco=73666.0,
)
rental_vehicle = RentalVehicle(
placa="RAA1A01",
modelo="Chevrolet Tracker",
categoria="suv",
ano=2024,
valor_diaria=219.90,
status="disponivel",
)
db.add_all([reserved_sale_vehicle, available_sale_vehicle, rental_vehicle])
db.flush()
db.add(
Order(
numero_pedido="PED-TESTE-0001",
cpf="12345678909",
vehicle_id=reserved_sale_vehicle.id,
modelo_veiculo=reserved_sale_vehicle.modelo,
valor_veiculo=reserved_sale_vehicle.preco,
status="Ativo",
)
)
db.commit()
finally:
db.close()
with patch("app.services.domain.inventory_service.SessionMockLocal", SessionLocal):
result = await consultar_estoque(preco_max=80000)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["modelo"], "Renault Duster 2022")
self.assertEqual(result[0]["categoria"], "suv")
self.assertTrue(all(item["modelo"] != "Chevrolet Tracker" for item in result))

@ -0,0 +1,44 @@
import unittest
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.db import mock_seed as mock_seed_module
from app.db.mock_database import MockBase
from app.db.mock_models import RentalVehicle, Vehicle
class RentalSeedTests(unittest.TestCase):
def _build_session_local(self):
engine = create_engine("sqlite:///:memory:")
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
MockBase.metadata.create_all(bind=engine)
self.addCleanup(engine.dispose)
return SessionLocal
def test_seed_mock_data_creates_independent_sales_and_rental_fleets(self):
SessionLocal = self._build_session_local()
with patch("app.db.mock_seed.SessionMockLocal", SessionLocal), patch.object(
mock_seed_module.settings,
"mock_seed_enabled",
True,
):
mock_seed_module.seed_mock_data()
db = SessionLocal()
try:
self.assertEqual(db.query(Vehicle).count(), mock_seed_module.TARGET_VEHICLE_COUNT)
self.assertEqual(db.query(RentalVehicle).count(), len(mock_seed_module.RENTAL_FLEET))
self.assertGreater(
db.query(RentalVehicle).filter(RentalVehicle.status == "disponivel").count(),
0,
)
self.assertGreater(
db.query(RentalVehicle).filter(RentalVehicle.status == "alugado").count(),
0,
)
finally:
db.close()

@ -0,0 +1,251 @@
import unittest
from datetime import datetime
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.db.mock_database import MockBase
from app.db.mock_models import RentalContract, RentalFine, RentalPayment, RentalVehicle
from app.services.domain import rental_service
class RentalServiceTests(unittest.IsolatedAsyncioTestCase):
def _build_session_local(self):
engine = create_engine("sqlite:///:memory:")
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
MockBase.metadata.create_all(bind=engine)
self.addCleanup(engine.dispose)
return SessionLocal
def _create_rental_vehicle(
self,
db,
*,
placa: str = "ABC1D23",
modelo: str = "Chevrolet Tracker",
categoria: str = "suv",
ano: int = 2024,
valor_diaria: float = 219.9,
status: str = "disponivel",
) -> RentalVehicle:
vehicle = RentalVehicle(
placa=placa,
modelo=modelo,
categoria=categoria,
ano=ano,
valor_diaria=valor_diaria,
status=status,
)
db.add(vehicle)
db.commit()
db.refresh(vehicle)
return vehicle
def _create_rental_contract(
self,
db,
vehicle: RentalVehicle,
*,
contrato_numero: str = "LOC-20260317-TESTE000",
user_id: int | None = None,
status: str = "ativa",
data_inicio: datetime | None = None,
data_fim_prevista: datetime | None = None,
) -> RentalContract:
contract = RentalContract(
contrato_numero=contrato_numero,
user_id=user_id,
rental_vehicle_id=vehicle.id,
placa=vehicle.placa,
modelo_veiculo=vehicle.modelo,
categoria=vehicle.categoria,
data_inicio=data_inicio or datetime(2026, 3, 17, 10, 0),
data_fim_prevista=data_fim_prevista or datetime(2026, 3, 20, 10, 0),
valor_diaria=float(vehicle.valor_diaria),
valor_previsto=round(float(vehicle.valor_diaria) * 3, 2),
status=status,
)
db.add(contract)
db.commit()
db.refresh(contract)
return contract
async def test_consultar_frota_aluguel_retains_only_available_by_default(self):
SessionLocal = self._build_session_local()
db = SessionLocal()
try:
self._create_rental_vehicle(db, placa="AAA1A11", modelo="Chevrolet Tracker", status="disponivel")
self._create_rental_vehicle(db, placa="BBB2B22", modelo="Fiat Toro", categoria="pickup", status="alugado")
self._create_rental_vehicle(db, placa="CCC3C33", modelo="Jeep Renegade", status="manutencao")
finally:
db.close()
with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal):
result = await rental_service.consultar_frota_aluguel(valor_diaria_max=300)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["placa"], "AAA1A11")
self.assertEqual(result[0]["status"], "disponivel")
async def test_abrir_locacao_aluguel_cria_contrato_e_marca_veiculo_como_alugado(self):
SessionLocal = self._build_session_local()
db = SessionLocal()
try:
vehicle = self._create_rental_vehicle(db)
vehicle_id = vehicle.id
vehicle_placa = vehicle.placa
finally:
db.close()
with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal):
result = await rental_service.abrir_locacao_aluguel(
placa=vehicle_placa,
data_inicio="17/03/2026 10:00",
data_fim_prevista="20/03/2026 10:00",
)
db = SessionLocal()
try:
stored_contract = db.query(RentalContract).one()
stored_vehicle = db.query(RentalVehicle).filter(RentalVehicle.id == vehicle_id).one()
self.assertEqual(stored_contract.placa, vehicle_placa)
self.assertEqual(stored_contract.status, "ativa")
self.assertEqual(stored_vehicle.status, "alugado")
self.assertEqual(result["status"], "ativa")
self.assertEqual(result["status_veiculo"], "alugado")
finally:
db.close()
async def test_registrar_devolucao_aluguel_fecha_contrato_e_libera_veiculo(self):
SessionLocal = self._build_session_local()
db = SessionLocal()
try:
vehicle = self._create_rental_vehicle(db, status="alugado")
vehicle_id = vehicle.id
vehicle_diaria = float(vehicle.valor_diaria)
contract = self._create_rental_contract(db, vehicle)
contract_number = contract.contrato_numero
finally:
db.close()
with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal):
result = await rental_service.registrar_devolucao_aluguel(
contrato_numero=contract_number,
data_devolucao="21/03/2026 09:00",
)
db = SessionLocal()
try:
stored_contract = db.query(RentalContract).one()
stored_vehicle = db.query(RentalVehicle).filter(RentalVehicle.id == vehicle_id).one()
self.assertEqual(stored_contract.status, "encerrada")
self.assertEqual(stored_vehicle.status, "disponivel")
self.assertEqual(result["status"], "encerrada")
self.assertEqual(result["status_veiculo"], "disponivel")
self.assertEqual(result["valor_final"], round(vehicle_diaria * 4, 2))
finally:
db.close()
async def test_registrar_pagamento_aluguel_persiste_registro(self):
SessionLocal = self._build_session_local()
with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal):
result = await rental_service.registrar_pagamento_aluguel(
contrato_numero="loc-123",
placa="abc1234",
valor=1540.5,
data_pagamento="17/03/2026 14:30",
favorecido="Locadora XPTO",
identificador_comprovante="NSU123",
user_id=9,
)
db = SessionLocal()
try:
stored = db.query(RentalPayment).one()
self.assertEqual(stored.contrato_numero, "LOC-123")
self.assertEqual(stored.placa, "ABC1234")
self.assertEqual(float(stored.valor), 1540.5)
self.assertEqual(result["status"], "registrado")
finally:
db.close()
async def test_registrar_pagamento_aluguel_vincula_unica_locacao_ativa_do_usuario(self):
SessionLocal = self._build_session_local()
db = SessionLocal()
try:
vehicle = self._create_rental_vehicle(db, status="alugado")
vehicle_placa = vehicle.placa
contract = self._create_rental_contract(db, vehicle, user_id=9)
finally:
db.close()
with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal):
result = await rental_service.registrar_pagamento_aluguel(
valor=879.90,
user_id=9,
)
db = SessionLocal()
try:
stored = db.query(RentalPayment).one()
self.assertEqual(stored.rental_contract_id, contract.id)
self.assertEqual(stored.contrato_numero, contract.contrato_numero)
self.assertEqual(stored.placa, vehicle_placa)
self.assertEqual(result["contrato_numero"], contract.contrato_numero)
finally:
db.close()
async def test_registrar_multa_aluguel_persiste_registro(self):
SessionLocal = self._build_session_local()
with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal):
result = await rental_service.registrar_multa_aluguel(
placa="abc1d23",
auto_infracao="A123456",
valor=293.47,
data_infracao="17/03/2026",
vencimento="10/04/2026",
orgao_emissor="DETRAN-SP",
user_id=11,
)
db = SessionLocal()
try:
stored = db.query(RentalFine).one()
self.assertEqual(stored.placa, "ABC1D23")
self.assertEqual(stored.auto_infracao, "A123456")
self.assertEqual(result["status"], "registrada")
finally:
db.close()
async def test_registrar_multa_aluguel_vincula_contrato_ativo_pela_placa(self):
SessionLocal = self._build_session_local()
db = SessionLocal()
try:
vehicle = self._create_rental_vehicle(db, placa="ABC1D23", status="alugado")
contract = self._create_rental_contract(db, vehicle, user_id=11)
finally:
db.close()
with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal):
result = await rental_service.registrar_multa_aluguel(
placa="ABC1D23",
auto_infracao="A123456",
valor=293.47,
user_id=11,
)
db = SessionLocal()
try:
stored = db.query(RentalFine).one()
self.assertEqual(stored.rental_contract_id, contract.id)
self.assertEqual(stored.contrato_numero, contract.contrato_numero)
self.assertEqual(result["contrato_numero"], contract.contrato_numero)
finally:
db.close()
if __name__ == "__main__":
unittest.main()

@ -0,0 +1,71 @@
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from app.integrations.telegram_satellite_service import TelegramSatelliteService
class _DummySession:
def close(self):
return None
class TelegramMultimodalTests(unittest.IsolatedAsyncioTestCase):
async def test_process_message_uses_extracted_image_message(self):
service = TelegramSatelliteService("token-teste")
tools_db = _DummySession()
mock_db = _DummySession()
with patch("app.integrations.telegram_satellite_service.SessionLocal", return_value=tools_db), patch(
"app.integrations.telegram_satellite_service.SessionMockLocal",
return_value=mock_db,
), patch("app.integrations.telegram_satellite_service.UserService") as user_service_cls, patch(
"app.integrations.telegram_satellite_service.OrquestradorService"
) as orchestrator_cls, patch.object(
service,
"_build_orchestration_message_from_image",
AsyncMock(return_value="[imagem recebida no telegram]\nDados extraidos da imagem: Registrar multa de aluguel: placa ABC1D23; valor 293,47; auto_infracao A123456."),
):
user_service_cls.return_value.get_or_create.return_value = SimpleNamespace(id=7)
orchestrator_cls.return_value.handle_message = AsyncMock(return_value="ok")
answer = await service._process_message(
text="segue a multa",
sender={"id": 99, "first_name": "Vitor"},
chat_id=99,
image_attachments=[{"mime_type": "image/jpeg", "data": b"123"}],
)
self.assertEqual(answer, "ok")
orchestrator_cls.return_value.handle_message.assert_awaited_once()
kwargs = orchestrator_cls.return_value.handle_message.await_args.kwargs
self.assertIn("Registrar multa de aluguel", kwargs["message"])
self.assertEqual(kwargs["user_id"], 7)
async def test_process_message_returns_direct_failure_for_unreadable_image(self):
service = TelegramSatelliteService("token-teste")
tools_db = _DummySession()
mock_db = _DummySession()
with patch("app.integrations.telegram_satellite_service.SessionLocal", return_value=tools_db), patch(
"app.integrations.telegram_satellite_service.SessionMockLocal",
return_value=mock_db,
), patch("app.integrations.telegram_satellite_service.UserService") as user_service_cls, patch(
"app.integrations.telegram_satellite_service.OrquestradorService"
) as orchestrator_cls, patch.object(
service,
"_build_orchestration_message_from_image",
AsyncMock(return_value="Nao consegui identificar os dados da imagem. Descreva o documento ou envie uma foto mais nitida."),
):
user_service_cls.return_value.get_or_create.return_value = SimpleNamespace(id=7)
orchestrator_cls.return_value.handle_message = AsyncMock()
answer = await service._process_message(
text="",
sender={"id": 99},
chat_id=99,
image_attachments=[{"mime_type": "image/jpeg", "data": b"123"}],
)
self.assertIn("Nao consegui identificar os dados da imagem", answer)
self.assertFalse(orchestrator_cls.return_value.handle_message.await_count)

@ -11,6 +11,7 @@ from app.services.orchestration.conversation_policy import ConversationPolicy
from app.services.orchestration.entity_normalizer import EntityNormalizer from app.services.orchestration.entity_normalizer import EntityNormalizer
from app.services.orchestration.message_planner import MessagePlanner from app.services.orchestration.message_planner import MessagePlanner
from app.services.orchestration.orquestrador_service import OrquestradorService from app.services.orchestration.orquestrador_service import OrquestradorService
from app.services.orchestration.tool_executor import ToolExecutor
class FakeLLM: class FakeLLM:
@ -80,6 +81,16 @@ class FakeToolExecutor:
} }
class StaticToolRegistry:
def __init__(self, result=None):
self.result = result if result is not None else {"ok": True}
self.calls = []
async def execute(self, tool_name: str, arguments: dict, user_id: int | None = None):
self.calls.append((tool_name, arguments, user_id))
return self.result
class FakePolicyService: class FakePolicyService:
def __init__(self, state): def __init__(self, state):
self.state = state self.state = state
@ -149,7 +160,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
"action": "ask_missing_fields", "action": "ask_missing_fields",
"entities": { "entities": {
"generic_memory": {}, "generic_memory": {},
"review_fields": {"placa": "abc1234", "data_hora": "10/03/2026 às 09:00"}, "review_fields": {"placa": "abc1234", "data_hora": "10/03/2026 \u00e0s 09:00"},
"review_management_fields": {}, "review_management_fields": {},
"order_fields": {}, "order_fields": {},
"cancel_order_fields": {} "cancel_order_fields": {}
@ -166,14 +177,14 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
) )
planner = MessagePlanner(llm=llm, normalizer=EntityNormalizer()) planner = MessagePlanner(llm=llm, normalizer=EntityNormalizer())
decision = await planner.extract_turn_decision("Quero agendar revisão amanhã às 09:00", user_id=7) decision = await planner.extract_turn_decision("Quero agendar revis\u00e3o amanh\u00e3 \u00e0s 09:00", user_id=7)
self.assertEqual(llm.calls, 2) self.assertEqual(llm.calls, 2)
self.assertEqual(decision["intent"], "review_schedule") self.assertEqual(decision["intent"], "review_schedule")
self.assertEqual(decision["domain"], "review") self.assertEqual(decision["domain"], "review")
self.assertEqual(decision["action"], "ask_missing_fields") self.assertEqual(decision["action"], "ask_missing_fields")
self.assertEqual(decision["entities"]["review_fields"]["placa"], "ABC1234") self.assertEqual(decision["entities"]["review_fields"]["placa"], "ABC1234")
self.assertEqual(decision["entities"]["review_fields"]["data_hora"], "10/03/2026 às 09:00") self.assertEqual(decision["entities"]["review_fields"]["data_hora"], "10/03/2026 \u00e0s 09:00")
self.assertEqual(decision["missing_fields"], ["modelo", "ano", "km"]) self.assertEqual(decision["missing_fields"], ["modelo", "ano", "km"])
def test_parse_json_object_accepts_python_style_dict_with_trailing_commas(self): def test_parse_json_object_accepts_python_style_dict_with_trailing_commas(self):
@ -972,6 +983,52 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response, "Pedido PED-1 atualizado.\nStatus: Cancelado") self.assertEqual(response, "Pedido PED-1 atualizado.\nStatus: Cancelado")
self.assertEqual(service.llm.calls, 0) self.assertEqual(service.llm.calls, 0)
async def test_turn_decision_rental_fleet_listing_uses_deterministic_response_without_result_llm(self):
registry = StaticToolRegistry(
result=[
{"id": 1, "placa": "RAA1A01", "modelo": "Chevrolet Tracker", "categoria": "suv", "ano": 2024, "valor_diaria": 219.9, "status": "disponivel"},
{"id": 2, "placa": "RAA1A02", "modelo": "Fiat Pulse", "categoria": "suv", "ano": 2024, "valor_diaria": 189.9, "status": "disponivel"},
]
)
service = OrquestradorService.__new__(OrquestradorService)
service.state = FakeState()
service.normalizer = EntityNormalizer()
service.tool_executor = ToolExecutor(registry=registry)
service.llm = FakeLLM([])
service._capture_review_confirmation_suggestion = lambda **kwargs: None
service._capture_tool_result_context = lambda **kwargs: None
service._capture_tool_invocation_trace = lambda **kwargs: None
service._log_turn_event = lambda *args, **kwargs: None
async def fake_render_tool_response_with_fallback(**kwargs):
raise AssertionError("nao deveria usar llm para listagem de locacao")
service._render_tool_response_with_fallback = fake_render_tool_response_with_fallback
service._http_exception_detail = lambda exc: str(exc)
service._is_low_value_response = lambda text: False
async def finish(response: str, queue_notice: str | None = None) -> str:
return response if not queue_notice else f"{queue_notice}\n{response}"
response = await service._try_execute_business_tool_from_turn_decision(
message="quais carros estao disponiveis para aluguel",
user_id=7,
turn_decision={
"action": "call_tool",
"tool_name": "consultar_frota_aluguel",
"tool_arguments": {"valor_diaria_max": 220},
},
queue_notice=None,
finish=finish,
)
self.assertEqual(registry.calls[0][0], "consultar_frota_aluguel")
self.assertEqual(registry.calls[0][2], 7)
self.assertIn("Encontrei 2 veiculo(s) para locacao:", response)
self.assertIn("RAA1A01", response)
self.assertIn("numero da lista, a placa ou o modelo", response)
self.assertEqual(service.llm.calls, 0)
async def test_confirm_pending_review_clears_open_review_draft_after_suggested_time_success(self): async def test_confirm_pending_review_clears_open_review_draft_after_suggested_time_success(self):
state = FakeState( state = FakeState(
entries={ entries={
@ -2156,6 +2213,82 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("CPF do cliente", response) self.assertIn("CPF do cliente", response)
async def test_handle_message_short_circuits_llm_when_pending_rental_selection_matches_list_choice(self):
state = FakeState(
entries={
"pending_rental_selections": {
1: {
"payload": [
{"id": 1, "placa": "RAA1A01", "modelo": "Chevrolet Tracker", "categoria": "suv", "ano": 2024, "valor_diaria": 219.9, "status": "disponivel"},
{"id": 2, "placa": "RAA1A02", "modelo": "Fiat Pulse", "categoria": "suv", "ano": 2024, "valor_diaria": 189.9, "status": "disponivel"},
],
"expires_at": utc_now() + timedelta(minutes=15),
}
}
},
contexts={
1: {
"active_domain": "rental",
"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,
}
}
)
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 para selecao pendente 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):
return "Para abrir a locacao, preciso dos dados abaixo:\n- a data e hora de inicio da locacao"
service._try_collect_and_open_rental = fake_try_collect_and_open_rental
response = await service.handle_message(
"1",
user_id=1,
)
self.assertIn("inicio da locacao", response)
async def test_handle_message_keeps_sales_flow_when_cpf_follow_up_is_misclassified_as_review(self): async def test_handle_message_keeps_sales_flow_when_cpf_follow_up_is_misclassified_as_review(self):
state = FakeState( state = FakeState(
entries={ entries={

Loading…
Cancel
Save