Merge branch 'feat/aluguel-multimodal'

main
commit 74e83adc66

@ -96,18 +96,24 @@ Antes de ativar o servico, rode uma inicializacao manual para validar banco e se
```bash
cd /opt/orquestrador
source venv/bin/activate
python -m app.db.init_db
python -m app.db.bootstrap
```
## 7) Configurar `systemd`
Copie o template:
Copie o template principal:
```bash
sudo cp deploy/systemd/orquestrador.service.example /etc/systemd/system/orquestrador.service
sudo nano /etc/systemd/system/orquestrador.service
```
Se quiser um atalho explicito para bootstrap manual via `systemd`, existe tambem o template:
```bash
sudo cp deploy/systemd/orquestrador-bootstrap.service.example /etc/systemd/system/orquestrador-bootstrap.service
```
Depois recarregue e inicie:
```bash
@ -143,6 +149,9 @@ cd /opt/orquestrador
git pull origin main
source venv/bin/activate
pip install -r requirements.txt
# rode o bootstrap apenas quando houver mudanca de schema/seed
python -m app.db.bootstrap
sudo systemctl restart orquestrador
sudo systemctl status orquestrador
```

@ -29,5 +29,5 @@ COPY app /app/app
ENV PATH=/root/.local/bin:$PATH
# Sobe o bootstrap de banco e inicia o satelite do Telegram.
CMD ["sh", "-c", "python -m app.db.init_db && python -m app.integrations.telegram_satellite_service"]
# Inicia apenas o servico principal; bootstrap de banco e seed sao rotinas explicitas.
CMD ["python", "-m", "app.integrations.telegram_satellite_service"]

@ -156,17 +156,27 @@ O projeto usa duas conexoes MySQL:
- banco de tools
- banco mock de negocio
O bootstrap atual cria tabelas e executa seed por meio de:
- [app/db/init_db.py](/d:/vitor/Pessoal/PJ/Orquestrador/app/db/init_db.py)
O bootstrap agora e uma rotina dedicada e explicita:
- [app/db/bootstrap.py](/d:/vitor/Pessoal/PJ/Orquestrador/app/db/bootstrap.py)
- [app/db/init_db.py](/d:/vitor/Pessoal/PJ/Orquestrador/app/db/init_db.py) como alias legado de compatibilidade
Esse bootstrap e usado no container e pode ser executado manualmente antes do servico principal.
Importante:
- o container principal nao executa bootstrap automaticamente;
- o app HTTP legado nao executa bootstrap no startup;
- a preparacao de schema e seed deve ser rodada de forma explicita antes do servico principal quando necessario.
## Execucao Local
### Sem Docker
1. Configure as variaveis de ambiente com base em `.env.example`.
2. Inicialize banco e seed:
2. Inicialize banco e seed com a rotina dedicada:
```bash
python -m app.db.bootstrap
```
Alias legado ainda aceito:
```bash
python -m app.db.init_db
@ -184,8 +194,15 @@ O compose atual sobe:
- `mysql`
- `redis`
- `telegram`
- `bootstrap` como rotina opcional e dedicada via profile
Preparar banco e seed:
```bash
docker compose --profile bootstrap run --rm bootstrap
```
Subida completa:
Subida completa do atendimento:
```bash
docker compose up --build
@ -253,13 +270,19 @@ Observacao:
## Docker
O [Dockerfile](/d:/vitor/Pessoal/PJ/Orquestrador/Dockerfile) hoje sobe o servico principal do projeto:
O [Dockerfile](/d:/vitor/Pessoal/PJ/Orquestrador/Dockerfile) agora sobe apenas o servico principal do projeto:
```bash
python -m app.integrations.telegram_satellite_service
```
O bootstrap fica separado e pode ser executado quando necessario com:
```bash
python -m app.db.init_db && python -m app.integrations.telegram_satellite_service
python -m app.db.bootstrap
```
Isso deixa o container alinhado com o uso atual do sistema, sem assumir FastAPI como interface principal.
Isso evita que um restart do container recrie schema ou rode seed de forma implicita.
## Testes
@ -285,7 +308,9 @@ DEBUG=false python -m unittest discover -s tests -v
Os proximos ganhos mais valiosos para o projeto sao:
- persistir trilha de conversa e decisoes
- desacoplar bootstrap de banco do startup da aplicacao
- consolidar observabilidade por turno e por tool com baixo acoplamento operacional
- aumentar observabilidade por turno e por tool
- reduzir o tamanho do `OrquestradorService`
- consolidar documentacao operacional Telegram-first

@ -0,0 +1,81 @@
"""
Rotina dedicada de bootstrap de banco de dados.
Cria tabelas e executa seed inicial de forma explicita, fora do startup do app.
"""
from app.core.settings import settings
from app.db.database import Base, engine
from app.db.mock_database import MockBase, mock_engine
from app.db.models import Tool
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.tool_seed import seed_tools
def bootstrap_databases(
*,
run_tools_seed: bool | None = None,
run_mock_seed: bool | None = None,
) -> None:
"""Cria tabelas e executa seed inicial em ambos os bancos."""
print("Inicializando bancos...")
failures: list[str] = []
should_seed_tools = settings.auto_seed_tools if run_tools_seed is None else bool(run_tools_seed)
should_seed_mock = (
settings.auto_seed_mock and settings.mock_seed_enabled
if run_mock_seed is None
else bool(run_mock_seed)
)
try:
print("Criando tabelas MySQL (tools)...")
Base.metadata.create_all(bind=engine)
if should_seed_tools:
print("Populando tools iniciais...")
seed_tools()
else:
print("Seed de tools desabilitada por configuracao.")
print("MySQL tools OK.")
except Exception as exc:
print(f"Aviso: falha no MySQL (tools): {exc}")
failures.append(f"tools={exc}")
try:
print("Criando tabelas MySQL (dados ficticios)...")
MockBase.metadata.create_all(bind=mock_engine)
if should_seed_mock:
print("Populando dados ficticios iniciais...")
seed_mock_data()
else:
print("Seed mock desabilitada por configuracao.")
print("MySQL mock OK.")
except Exception as exc:
print(f"Aviso: falha no MySQL mock: {exc}")
failures.append(f"mock={exc}")
if failures:
raise RuntimeError(
"Falha ao inicializar bancos do orquestrador: " + " | ".join(failures)
)
print("Bancos inicializados com sucesso!")
def main() -> None:
"""Executa o bootstrap dedicado quando chamado via modulo."""
bootstrap_databases()
if __name__ == "__main__":
main()

@ -1,54 +1,14 @@
"""
Inicializacao de banco de dados.
Cria tabelas e executa seed inicial em ambos os bancos.
Compatibilidade para bootstrap legado.
Mantem o comando historico `python -m app.db.init_db` delegando para a rotina dedicada.
"""
from app.core.settings import settings
from app.db.database import Base, engine
from app.db.mock_database import MockBase, mock_engine
from app.db.models import Tool
from app.db.mock_models import ConversationTurn, Customer, Order, ReviewSchedule, Vehicle
from app.db.mock_seed import seed_mock_data
from app.db.tool_seed import seed_tools
from app.db.bootstrap import bootstrap_databases
def init_db():
"""Cria tabelas e executa seed inicial em ambos os bancos."""
print("Inicializando bancos...")
failures: list[str] = []
try:
print("Criando tabelas MySQL (tools)...")
Base.metadata.create_all(bind=engine)
if settings.auto_seed_tools:
print("Populando tools iniciais...")
seed_tools()
else:
print("Seed de tools desabilitada por configuracao.")
print("MySQL tools OK.")
except Exception as exc:
print(f"Aviso: falha no MySQL (tools): {exc}")
failures.append(f"tools={exc}")
try:
print("Criando tabelas MySQL (dados ficticios)...")
MockBase.metadata.create_all(bind=mock_engine)
if settings.auto_seed_mock and settings.mock_seed_enabled:
print("Populando dados ficticios iniciais...")
seed_mock_data()
else:
print("Seed mock desabilitada por configuracao.")
print("MySQL mock OK.")
except Exception as exc:
print(f"Aviso: falha no MySQL mock: {exc}")
failures.append(f"mock={exc}")
if failures:
raise RuntimeError(
"Falha ao inicializar bancos do orquestrador: " + " | ".join(failures)
)
print("Bancos inicializados com sucesso!")
def init_db() -> None:
"""Mantem compatibilidade com o nome legado do bootstrap."""
bootstrap_databases()
if __name__ == "__main__":

@ -81,6 +81,82 @@ class ReviewSchedule(MockBase):
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):
__tablename__ = "conversation_turns"

@ -3,7 +3,7 @@ from datetime import datetime
from app.core.settings import settings
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 = [
@ -59,6 +59,33 @@ VEHICLE_PRICE_BANDS = (
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:
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.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()
if customer_count < TARGET_CUSTOMER_COUNT:
customers = _seed_customer_records(
@ -164,4 +208,3 @@ def seed_mock_data() -> None:
db.commit()
finally:
db.close()

@ -285,6 +285,207 @@ def get_tools_definitions():
"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",
"description": (

@ -2,22 +2,38 @@ import asyncio
import logging
import os
import tempfile
from datetime import timedelta
from typing import Any, Dict, List
import aiohttp
from fastapi import HTTPException
from app.core.settings import settings
from app.core.time_utils import utc_now
from app.db.database import SessionLocal
from app.db.mock_database import SessionMockLocal
from app.services.ai.llm_service import LLMService
from app.services.ai.llm_service import (
IMAGE_ANALYSIS_BLOCKING_PREFIXES,
LLMService,
)
from app.services.orchestration.conversation_state_repository import ConversationStateRepository
from app.services.orchestration.orquestrador_service import OrquestradorService
from app.services.orchestration.sensitive_data import mask_sensitive_payload
from app.services.orchestration.state_repository_factory import get_conversation_state_repository
from app.services.user.user_service import UserService
logger = logging.getLogger(__name__)
TELEGRAM_MESSAGE_SAFE_LIMIT = 3800
TELEGRAM_MAX_CONCURRENT_CHATS = 8
TELEGRAM_IDEMPOTENCY_BUCKET = "telegram_processed_messages"
TELEGRAM_IDEMPOTENCY_CACHE_LIMIT = 100
TELEGRAM_RUNTIME_BUCKET = "telegram_runtime_state"
TELEGRAM_RUNTIME_OWNER_ID = 0
TELEGRAM_RUNTIME_CURSOR_TTL_DAYS = 30
TELEGRAM_SEND_MESSAGE_MAX_ATTEMPTS = 3
TELEGRAM_SEND_MESSAGE_RETRY_BASE_SECONDS = 1.0
def _split_telegram_text(text: str, limit: int = TELEGRAM_MESSAGE_SAFE_LIMIT) -> List[str]:
@ -120,12 +136,22 @@ class TelegramSatelliteService:
Processa mensagens direto no OrquestradorService e publica respostas no chat.
"""
def __init__(self, token: str):
def __init__(
self,
token: str,
state_repository: ConversationStateRepository | None = None,
):
"""Configura cliente Telegram com URL base e timeouts padrao."""
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.request_timeout = settings.telegram_request_timeout
self.state = state_repository or get_conversation_state_repository()
self._last_update_id = -1
self._chat_queues: dict[int, asyncio.Queue[Dict[str, Any]]] = {}
self._chat_workers: dict[int, asyncio.Task[None]] = {}
self._chat_workers_lock = asyncio.Lock()
self._chat_processing_semaphore = asyncio.Semaphore(TELEGRAM_MAX_CONCURRENT_CHATS)
async def run(self) -> None:
"""Inicia loop de long polling para consumir atualizacoes do bot."""
@ -135,6 +161,7 @@ class TelegramSatelliteService:
timeout = aiohttp.ClientTimeout(total=self.request_timeout)
async with aiohttp.ClientSession(timeout=timeout) as session:
try:
offset = await self._initialize_offset(session=session)
while True:
updates = await self._get_updates(session=session, offset=offset)
@ -146,7 +173,182 @@ class TelegramSatelliteService:
continue
self._last_update_id = update_id
offset = update_id + 1
await self._schedule_update_processing(session=session, update=update)
finally:
await self._shutdown_chat_workers()
def _extract_chat_id(self, update: Dict[str, Any]) -> int | None:
message = update.get("message", {})
chat = message.get("chat", {})
chat_id = chat.get("id")
return chat_id if isinstance(chat_id, int) else None
def _build_update_idempotency_key(self, update: Dict[str, Any]) -> str | None:
chat_id = self._extract_chat_id(update)
message = update.get("message", {})
message_id = message.get("message_id")
if isinstance(chat_id, int) and isinstance(message_id, int):
return f"telegram:message:{chat_id}:{message_id}"
update_id = update.get("update_id")
if isinstance(update_id, int):
return f"telegram:update:{update_id}"
return None
def _idempotency_owner_id(self, update: Dict[str, Any]) -> int | None:
chat_id = self._extract_chat_id(update)
if isinstance(chat_id, int):
return chat_id
update_id = update.get("update_id")
return update_id if isinstance(update_id, int) else None
def _get_processed_update(self, update: Dict[str, Any]) -> dict | None:
owner_id = self._idempotency_owner_id(update)
idempotency_key = self._build_update_idempotency_key(update)
if owner_id is None or not idempotency_key:
return None
entry = self.state.get_entry(TELEGRAM_IDEMPOTENCY_BUCKET, owner_id, expire=True)
if not isinstance(entry, dict):
return None
items = entry.get("items")
if not isinstance(items, dict):
return None
payload = items.get(idempotency_key)
return payload if isinstance(payload, dict) else None
def _store_processed_update(self, update: Dict[str, Any], answer: str) -> None:
owner_id = self._idempotency_owner_id(update)
idempotency_key = self._build_update_idempotency_key(update)
if owner_id is None or not idempotency_key:
return
now = utc_now().replace(microsecond=0)
expires_at = now + timedelta(minutes=settings.conversation_state_ttl_minutes)
entry = self.state.get_entry(TELEGRAM_IDEMPOTENCY_BUCKET, owner_id, expire=True) or {}
items = dict(entry.get("items") or {})
items[idempotency_key] = {
"answer": str(answer or ""),
"processed_at": now,
}
if len(items) > TELEGRAM_IDEMPOTENCY_CACHE_LIMIT:
ordered = sorted(
items.items(),
key=lambda item: item[1].get("processed_at") or now,
reverse=True,
)
items = dict(ordered[:TELEGRAM_IDEMPOTENCY_CACHE_LIMIT])
self.state.set_entry(
TELEGRAM_IDEMPOTENCY_BUCKET,
owner_id,
{
"items": items,
"expires_at": expires_at,
},
)
def _get_runtime_state(self) -> dict:
entry = self.state.get_entry(TELEGRAM_RUNTIME_BUCKET, TELEGRAM_RUNTIME_OWNER_ID)
return entry if isinstance(entry, dict) else {}
def _persist_last_processed_update_id(self, update_id: int) -> None:
if update_id < 0:
return
entry = self._get_runtime_state()
current_last_update_id = entry.get("last_update_id")
if isinstance(current_last_update_id, int) and current_last_update_id >= update_id:
self._last_update_id = max(self._last_update_id, current_last_update_id)
return
now = utc_now().replace(microsecond=0)
expires_at = now + timedelta(days=TELEGRAM_RUNTIME_CURSOR_TTL_DAYS)
self.state.set_entry(
TELEGRAM_RUNTIME_BUCKET,
TELEGRAM_RUNTIME_OWNER_ID,
{
"last_update_id": update_id,
"updated_at": now,
"expires_at": expires_at,
},
)
self._last_update_id = max(self._last_update_id, update_id)
async def _schedule_update_processing(
self,
session: aiohttp.ClientSession,
update: Dict[str, Any],
) -> None:
chat_id = self._extract_chat_id(update)
if chat_id is None:
async with self._chat_processing_semaphore:
await self._handle_update(session=session, update=update)
return
async with self._chat_workers_lock:
queue = self._chat_queues.get(chat_id)
if queue is None:
queue = asyncio.Queue()
self._chat_queues[chat_id] = queue
queue.put_nowait(update)
worker = self._chat_workers.get(chat_id)
if worker is None or worker.done():
self._chat_workers[chat_id] = asyncio.create_task(
self._run_chat_worker(
chat_id=chat_id,
session=session,
queue=queue,
)
)
async def _run_chat_worker(
self,
*,
chat_id: int,
session: aiohttp.ClientSession,
queue: asyncio.Queue[Dict[str, Any]],
) -> None:
current_task = asyncio.current_task()
try:
while True:
update = await queue.get()
try:
async with self._chat_processing_semaphore:
await self._handle_update(session=session, update=update)
finally:
queue.task_done()
async with self._chat_workers_lock:
if queue.empty():
if self._chat_workers.get(chat_id) is current_task:
self._chat_workers.pop(chat_id, None)
if self._chat_queues.get(chat_id) is queue:
self._chat_queues.pop(chat_id, None)
return
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Falha inesperada no worker do chat %s.", chat_id)
finally:
async with self._chat_workers_lock:
if self._chat_workers.get(chat_id) is current_task:
self._chat_workers.pop(chat_id, None)
if self._chat_queues.get(chat_id) is queue and queue.empty():
self._chat_queues.pop(chat_id, None)
async def _shutdown_chat_workers(self) -> None:
async with self._chat_workers_lock:
workers = list(self._chat_workers.values())
self._chat_workers = {}
self._chat_queues = {}
for worker in workers:
worker.cancel()
if workers:
await asyncio.gather(*workers, return_exceptions=True)
async def _warmup_llm(self) -> None:
"""Preaquece o LLM no startup do satelite para reduzir latencia do primeiro usuario."""
@ -158,9 +360,16 @@ class TelegramSatelliteService:
async def _initialize_offset(self, session: aiohttp.ClientSession) -> int | None:
"""
Descarta backlog pendente no startup para evitar respostas repetidas apos restart.
Retorna o offset inicial seguro para o loop principal.
Retoma o polling a partir do ultimo update persistido.
Sem cursor salvo, faz um bootstrap conservador e registra o ponto inicial.
"""
runtime_state = self._get_runtime_state()
last_update_id = runtime_state.get("last_update_id")
if isinstance(last_update_id, int) and last_update_id >= 0:
self._last_update_id = last_update_id
logger.info("Retomando polling do Telegram a partir do update_id persistido %s.", last_update_id)
return last_update_id + 1
payload: Dict[str, Any] = {
"timeout": 0,
"limit": 100,
@ -180,8 +389,11 @@ class TelegramSatelliteService:
if last_id < 0:
return None
self._last_update_id = last_id
logger.info("Startup com backlog descartado: %s update(s) anteriores ignorados.", len(updates))
self._persist_last_processed_update_id(last_id)
logger.info(
"Bootstrap inicial do Telegram sem cursor persistido: %s update(s) anteriores ignorados.",
len(updates),
)
return last_id + 1
async def _get_updates(
@ -212,24 +424,62 @@ class TelegramSatelliteService:
) -> None:
"""Processa uma atualizacao recebida e envia resposta ao chat."""
message = update.get("message", {})
text = message.get("text")
text = message.get("text") or message.get("caption")
chat = message.get("chat", {})
chat_id = chat.get("id")
sender = message.get("from", {})
if not text or not chat_id:
if not chat_id:
return
cached_update = self._get_processed_update(update)
if cached_update:
cached_answer = str(cached_update.get("answer") or "").strip()
if cached_answer:
logger.info(
"Reutilizando resposta em reentrega do Telegram. chat_id=%s update_key=%s",
chat_id,
self._build_update_idempotency_key(update),
)
await self._deliver_message(session=session, chat_id=chat_id, text=cached_answer)
return
image_attachments = await self._extract_image_attachments(session=session, message=message)
if not text and not image_attachments:
return
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:
logger.warning("Falha de dominio ao processar mensagem no Telegram: %s", exc.detail)
logger.warning("Falha de dominio ao processar mensagem no Telegram: %s", mask_sensitive_payload(exc.detail))
answer = str(exc.detail) if exc.detail else "Nao foi possivel concluir a operacao solicitada."
except Exception:
logger.exception("Erro ao processar mensagem do Telegram.")
answer = "Nao consegui processar sua solicitacao agora. Tente novamente em instantes."
await self._send_message(session=session, chat_id=chat_id, text=answer)
self._store_processed_update(update=update, answer=answer)
update_id = update.get("update_id")
if isinstance(update_id, int):
self._persist_last_processed_update_id(update_id)
await self._deliver_message(session=session, chat_id=chat_id, text=answer)
async def _deliver_message(
self,
*,
session: aiohttp.ClientSession,
chat_id: int,
text: str,
) -> None:
"""Entrega a resposta ao Telegram sem deixar falhas de transporte derrubarem o worker."""
try:
await self._send_message(session=session, chat_id=chat_id, text=text)
except Exception:
logger.exception("Falha inesperada ao entregar mensagem ao Telegram. chat_id=%s", chat_id)
async def _send_message(
self,
@ -238,18 +488,77 @@ class TelegramSatelliteService:
text: str,
) -> None:
"""Envia mensagem de texto para o chat informado no Telegram."""
for chunk in _split_telegram_text(text):
for chunk_index, chunk in enumerate(_split_telegram_text(text), start=1):
payload = {
"chat_id": chat_id,
"text": chunk,
}
for attempt in range(1, TELEGRAM_SEND_MESSAGE_MAX_ATTEMPTS + 1):
try:
async with session.post(f"{self.base_url}/sendMessage", json=payload) as response:
data = await response.json()
if not data.get("ok"):
logger.warning("Falha em sendMessage: %s", data)
break
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as exc:
if attempt >= TELEGRAM_SEND_MESSAGE_MAX_ATTEMPTS:
logger.warning(
"Falha de transporte ao enviar mensagem ao Telegram apos %s tentativa(s). chat_id=%s chunk=%s erro=%s",
TELEGRAM_SEND_MESSAGE_MAX_ATTEMPTS,
chat_id,
chunk_index,
exc,
)
break
delay_seconds = TELEGRAM_SEND_MESSAGE_RETRY_BASE_SECONDS * attempt
logger.warning(
"Falha temporaria ao enviar mensagem ao Telegram. chat_id=%s chunk=%s tentativa=%s/%s retry_em=%.1fs erro=%s",
chat_id,
chunk_index,
attempt,
TELEGRAM_SEND_MESSAGE_MAX_ATTEMPTS,
delay_seconds,
exc,
)
await asyncio.sleep(delay_seconds)
async def _process_message(self, text: str, sender: Dict[str, Any], chat_id: int) -> str:
# Processa uma mensagem do Telegram e injeta o texto extraido de imagens quando houver.
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."""
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
return await asyncio.to_thread(
self._run_blocking_orchestration_turn,
message_text=message_text,
sender=sender,
chat_id=chat_id,
)
def _run_blocking_orchestration_turn(
self,
*,
message_text: str,
sender: Dict[str, Any],
chat_id: int,
) -> str:
"""
Executa o turno do orquestrador fora do loop async principal.
Isso isola sessoes SQLAlchemy sincronas e outras operacoes bloqueantes.
"""
tools_db = SessionLocal()
mock_db = SessionMockLocal()
try:
@ -267,13 +576,123 @@ class TelegramSatelliteService:
username=username,
)
service = OrquestradorService(tools_db)
return await service.handle_message(message=text, user_id=user.id)
service = OrquestradorService(
tools_db,
state_repository=self.state,
)
return asyncio.run(service.handle_message(message=message_text, user_id=user.id))
finally:
tools_db.close()
mock_db.close()
# Filtra documentos do Telegram para aceitar apenas imagens.
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/")
# Reconhece a resposta padrao quando a leitura da imagem falha.
def _is_image_analysis_failure_message(self, text: str) -> bool:
normalized = str(text or "").strip().lower()
return any(normalized.startswith(prefix) for prefix in IMAGE_ANALYSIS_BLOCKING_PREFIXES)
# Extrai a melhor foto e documentos de imagem anexados na mensagem.
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
# Baixa um anexo de imagem do Telegram e devolve seu payload bruto.
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,
}
# Combina legenda e texto extraido da imagem em uma mensagem unica para o fluxo.
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:
"""Inicializa servico satelite do Telegram e inicia processamento continuo."""
token = settings.telegram_bot_token
@ -293,3 +712,4 @@ async def main() -> None:
if __name__ == "__main__":
asyncio.run(main())

@ -1,6 +1,5 @@
from fastapi import FastAPI
from app.db.init_db import init_db
from app.services.ai.llm_service import LLMService
app = FastAPI(title="AI Orquestrador")
@ -9,15 +8,13 @@ app = FastAPI(title="AI Orquestrador")
@app.on_event("startup")
async def startup_event():
"""
Inicializa o banco de dados e executa seeds automaticamente.
Realiza apenas inicializacao leve do app HTTP legado.
Bootstrap de banco e seed agora sao operacoes explicitas e separadas.
"""
print("[Startup] Iniciando bootstrap legado do app HTTP...")
init_db()
try:
await LLMService().warmup()
print("[Startup] LLM warmup concluido.")
except Exception as e:
print(f"[Startup] Aviso: falha no warmup do LLM: {e}")
print("[Startup] App HTTP legado inicializado.")
print("[Startup] App HTTP legado inicializado sem bootstrap automatico.")

@ -4,11 +4,18 @@ from typing import Dict, Any, List, Optional
import vertexai
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.models.tool_model import ToolDefinition
IMAGE_ANALYSIS_FAILURE_MESSAGE = "Nao consegui identificar os dados da imagem. Descreva o documento ou envie uma foto mais nitida."
INVALID_RECEIPT_WATERMARK_MESSAGE = "O comprovante enviado nao e valido. Envie um comprovante valido com a marca d'agua SysaltiIA visivel."
VALID_RECEIPT_WATERMARK_MARKER = "[watermark_sysaltiia_ok]"
IMAGE_ANALYSIS_BLOCKING_PREFIXES = (
IMAGE_ANALYSIS_FAILURE_MESSAGE.lower(),
INVALID_RECEIPT_WATERMARK_MESSAGE.lower(),
)
# Essa classe encapsula a integracao com o Vertex AI:
# inicializacao, cache de modelos e serializacao das tools.
@ -30,6 +37,105 @@ class LLMService:
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]
# Transforma anexos de imagem em uma mensagem textual pronta para o orquestrador.
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 = self._build_image_workflow_prompt(caption=caption)
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 IMAGE_ANALYSIS_FAILURE_MESSAGE
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)
extracted_text = (payload.get("response") or "").strip() or (caption or "").strip()
return self._coerce_image_workflow_response(extracted_text)
# Define o prompt de extracao usado para comprovantes e multas em imagem.
def _build_image_workflow_prompt(self, *, caption: str | None) -> str:
normalized_caption = (caption or "").strip() or "sem legenda"
return (
"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 a imagem for comprovante de pagamento ou nota fiscal, so considere o documento valido quando houver no fundo a marca d'agua exatamente escrita como SysaltiIA, com essa mesma grafia. "
f"Se essa marca d'agua SysaltiIA nao estiver visivel com clareza, responda exatamente: {INVALID_RECEIPT_WATERMARK_MESSAGE} "
f"Se o comprovante de pagamento ou a nota fiscal estiver valido com a marca d'agua correta, prefixe a resposta exatamente com {VALID_RECEIPT_WATERMARK_MARKER} e um espaco antes do texto final. "
"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 a data de pagamento incluir hora e minuto visiveis na imagem, preserve a data e a hora no campo data_pagamento no formato DD/MM/AAAA HH:MM. "
"Nao reduza para somente a data quando a hora estiver visivel. "
"Se apenas a data estiver visivel, use somente a data. "
"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. "
f"Se nao conseguir identificar com seguranca, responda exatamente: {IMAGE_ANALYSIS_FAILURE_MESSAGE} "
"Use apenas dados observaveis e nao invente informacoes. "
f"Legenda do usuario: {normalized_caption}"
)
# Aplica validacoes extras ao retorno multimodal antes de acionar o orquestrador.
def _coerce_image_workflow_response(self, text: str) -> str:
normalized = str(text or "").strip()
if not normalized:
return ""
lowered = normalized.lower()
marker = VALID_RECEIPT_WATERMARK_MARKER.lower()
if lowered.startswith(marker):
return normalized[len(VALID_RECEIPT_WATERMARK_MARKER):].strip()
if lowered.startswith(IMAGE_ANALYSIS_FAILURE_MESSAGE.lower()) or lowered.startswith(
INVALID_RECEIPT_WATERMARK_MESSAGE.lower()
):
return normalized
if self._looks_like_watermark_sensitive_image_response(normalized):
return INVALID_RECEIPT_WATERMARK_MESSAGE
return normalized
# Reconhece respostas que so deveriam seguir com a confirmacao da watermark.
def _looks_like_watermark_sensitive_image_response(self, text: str) -> bool:
normalized = str(text or "").strip().lower()
return bool(
normalized.startswith("registrar pagamento de aluguel:")
or normalized.startswith("nota fiscal")
or normalized.startswith("comprovante")
or "nota fiscal" in normalized
or "comprovante" in normalized
)
def build_vertex_tools(self, tools: List[ToolDefinition]) -> Optional[List[Tool]]:
"""Converte tools internas para o formato esperado pelo Vertex AI."""
# Vertex espera uma lista de Tool, com function_declarations agrupadas em um unico Tool.

@ -0,0 +1,619 @@
import math
import random
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
# Normaliza o numero do contrato para comparacoes e buscas.
def _normalize_contract_number(value: str | None) -> str | None:
text = str(value or "").strip().upper()
return text or None
# Limpa campos textuais livres antes de salvar ou responder.
def _normalize_text_field(value: str | None) -> str | None:
text = str(value or "").strip(" ,.;")
return text or None
# Converte datas opcionais de aluguel em datetime com formatos aceitos.
def _parse_optional_datetime(value: str | None, *, field_name: str) -> datetime | None:
text = str(value or "").strip()
if not text:
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
# Exige uma data obrigatoria de aluguel e reaproveita a validacao comum.
def _parse_required_datetime(value: str | None, *, field_name: str) -> datetime:
parsed = _parse_optional_datetime(value, field_name=field_name)
if parsed is None:
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
# Valida e normaliza valores monetarios positivos usados no fluxo.
def _normalize_money(value) -> float:
number = technical_normalizer.normalize_positive_number(value)
if number is None or float(number) <= 0:
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)
# Garante que o identificador do veiculo seja um inteiro positivo.
def _normalize_vehicle_id(value) -> int | None:
if value is None or value == "":
return None
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
# Calcula a quantidade de diarias cobradas entre inicio e fim da locacao.
def _calculate_rental_days(start: datetime, end: datetime) -> int:
delta_seconds = (end - start).total_seconds()
if delta_seconds < 0:
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))
# Busca o veiculo da locacao por id ou placa normalizada.
def _build_rental_vehicle_query(
db,
*,
rental_vehicle_id: int | None = None,
placa: str | None = None,
) -> Any:
if rental_vehicle_id is not None:
return db.query(RentalVehicle).filter(RentalVehicle.id == rental_vehicle_id)
normalized_plate = technical_normalizer.normalize_plate(placa)
if normalized_plate:
return db.query(RentalVehicle).filter(RentalVehicle.placa == normalized_plate)
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 db.query(RentalVehicle).filter(RentalVehicle.id.is_(None))
def _lookup_rental_vehicle(
db,
*,
rental_vehicle_id: int | None = None,
placa: str | None = None,
) -> RentalVehicle | None:
return _build_rental_vehicle_query(
db,
rental_vehicle_id=rental_vehicle_id,
placa=placa,
).first()
# Recupera e trava o veiculo no mesmo turno transacional para evitar dupla locacao.
def _get_rental_vehicle_for_update(
db,
*,
rental_vehicle_id: int | None = None,
placa: str | None = None,
) -> RentalVehicle | None:
return _build_rental_vehicle_query(
db,
rental_vehicle_id=rental_vehicle_id,
placa=placa,
).with_for_update().first()
# Prioriza contratos do proprio usuario antes de cair para contratos sem dono.
def _lookup_contract_by_user_preference(query, user_id: int | None):
if user_id is None:
return query.order_by(RentalContract.created_at.desc()).first()
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()
# Resolve um contrato de aluguel usando contrato, placa ou contexto do usuario.
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
# Lista a frota de aluguel com filtros simples e ordenacao configuravel.
async def consultar_frota_aluguel(
categoria: str | None = None,
valor_diaria_max: float | None = None,
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()}%"))
order_mode = str(ordenar_diaria or "").strip().lower()
normalized_limit = None
if limite is not None:
try:
normalized_limit = max(1, min(int(limite), 50))
except (TypeError, ValueError):
normalized_limit = None
if order_mode in {"asc", "desc"}:
query = query.order_by(
RentalVehicle.valor_diaria.asc()
if order_mode == "asc"
else RentalVehicle.valor_diaria.desc()
)
elif order_mode != "random":
query = query.order_by(RentalVehicle.valor_diaria.asc(), RentalVehicle.modelo.asc())
if order_mode == "random":
rows = query.all()
random.shuffle(rows)
if normalized_limit is not None:
rows = rows[:normalized_limit]
else:
if normalized_limit is not None:
query = query.limit(normalized_limit)
rows = query.all()
return [
{
"id": row.id,
"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()
# Abre uma locacao, reserva o veiculo e devolve o resumo do contrato.
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 = _get_rental_vehicle_for_update(
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()
# Encerra a locacao ativa, calcula o valor final e libera o veiculo.
async def registrar_devolucao_aluguel(
contrato_numero: str | None = None,
placa: str | None = None,
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()
# Registra um pagamento de aluguel e tenta vincular o contrato correto.
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()
# Registra uma multa ligada ao aluguel usando os identificadores disponiveis.
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()

@ -4,7 +4,9 @@ from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import HTTPException
from sqlalchemy import func
from sqlalchemy import func, text
from sqlalchemy.exc import OperationalError, SQLAlchemyError
from app.db.mock_database import SessionMockLocal
from app.db.mock_models import ReviewSchedule
@ -132,6 +134,49 @@ def _find_next_available_review_slot(
return None
def _review_slot_lock_name(requested_dt: datetime) -> str:
return f"orquestrador:review_slot:{_normalize_review_slot(requested_dt).isoformat()}"
def _acquire_review_slot_lock(
db,
*,
requested_dt: datetime,
timeout_seconds: int = 5,
field_name: str = "data_hora",
) -> str | None:
lock_name = _review_slot_lock_name(requested_dt)
try:
acquired = db.execute(
text("SELECT GET_LOCK(:lock_name, :timeout_seconds)"),
{"lock_name": lock_name, "timeout_seconds": timeout_seconds},
).scalar()
except (OperationalError, SQLAlchemyError):
return None
if int(acquired or 0) != 1:
raise_tool_http_error(
status_code=409,
code="review_slot_busy",
message="Outro atendimento esta finalizando este horario de revisao. Tente novamente.",
retryable=True,
field=field_name,
)
return lock_name
def _release_review_slot_lock(db, lock_name: str | None) -> None:
if not lock_name:
return
try:
db.execute(
text("SELECT RELEASE_LOCK(:lock_name)"),
{"lock_name": lock_name},
)
except (OperationalError, SQLAlchemyError):
pass
def build_review_conflict_detail(
requested_dt: datetime,
suggested_dt: datetime | None = None,
@ -223,7 +268,9 @@ async def agendar_revisao(
protocolo = f"REV-{dt.strftime('%Y%m%d')}-{entropy}"
db = SessionMockLocal()
review_slot_lock_name: str | None = None
try:
review_slot_lock_name = _acquire_review_slot_lock(db, requested_dt=dt)
conflito_horario = (
db.query(ReviewSchedule)
.filter(ReviewSchedule.data_hora == dt)
@ -279,6 +326,7 @@ async def agendar_revisao(
"valor_revisao": valor_revisao,
}
finally:
_release_review_slot_lock(db, review_slot_lock_name)
db.close()
@ -413,6 +461,7 @@ async def editar_data_revisao(
)
db = SessionMockLocal()
review_slot_lock_name: str | None = None
try:
agendamento = (
db.query(ReviewSchedule)
@ -437,6 +486,13 @@ async def editar_data_revisao(
retryable=False,
)
if agendamento.data_hora != nova_data:
review_slot_lock_name = _acquire_review_slot_lock(
db,
requested_dt=nova_data,
field_name="nova_data_hora",
)
conflito = (
db.query(ReviewSchedule)
.filter(ReviewSchedule.id != agendamento.id)
@ -465,4 +521,5 @@ async def editar_data_revisao(
"status": agendamento.status,
}
finally:
_release_review_slot_lock(db, review_slot_lock_name)
db.close()

@ -0,0 +1,131 @@
from __future__ import annotations
from app.core.time_utils import utc_now
class FlowStateSupport:
"""Utilitarios compartilhados para buckets e snapshots de fluxo."""
def __init__(self, service) -> None:
self.service = service
def get_state_repository(self):
return getattr(self.service, "state", None)
def get_state_entry(self, bucket: str, user_id: int | None, *, expire: bool = False):
state = self.get_state_repository()
if state is None or not hasattr(state, "get_entry"):
return None
return state.get_entry(bucket, user_id, expire=expire)
def set_state_entry(self, bucket: str, user_id: int | None, value) -> None:
state = self.get_state_repository()
if state is None or not hasattr(state, "set_entry"):
return
state.set_entry(bucket, user_id, value)
def pop_state_entry(self, bucket: str, user_id: int | None):
state = self.get_state_repository()
if state is None or not hasattr(state, "pop_entry"):
return None
return state.pop_entry(bucket, user_id)
def get_flow_snapshot(self, user_id: int | None, snapshot_key: str) -> dict | None:
if user_id is None or not hasattr(self.service, "_get_user_context"):
return None
context = self.service._get_user_context(user_id)
if not isinstance(context, dict):
return None
snapshots = context.get("flow_snapshots")
if not isinstance(snapshots, dict):
return None
snapshot = snapshots.get(snapshot_key)
return dict(snapshot) if isinstance(snapshot, dict) else None
def set_flow_snapshot(
self,
user_id: int | None,
snapshot_key: str,
value: dict | None,
*,
active_task: str | None = None,
) -> None:
if user_id is None or not hasattr(self.service, "_get_user_context") or not hasattr(self.service, "_save_user_context"):
return
context = self.service._get_user_context(user_id)
if not isinstance(context, dict):
return
snapshots = context.get("flow_snapshots")
if not isinstance(snapshots, dict):
snapshots = {}
context["flow_snapshots"] = snapshots
if isinstance(value, dict):
snapshots[snapshot_key] = value
if active_task:
context["active_task"] = active_task
collected_slots = context.get("collected_slots")
if not isinstance(collected_slots, dict):
collected_slots = {}
context["collected_slots"] = collected_slots
payload = value.get("payload")
if isinstance(payload, dict):
collected_slots[active_task] = dict(payload)
else:
snapshots.pop(snapshot_key, None)
if active_task and context.get("active_task") == active_task:
context["active_task"] = None
collected_slots = context.get("collected_slots")
if isinstance(collected_slots, dict) and active_task:
collected_slots.pop(active_task, None)
self.service._save_user_context(user_id=user_id, context=context)
def get_flow_entry(self, bucket: str, user_id: int | None, snapshot_key: str) -> dict | None:
entry = self.get_state_entry(bucket, user_id, expire=True)
if entry:
return entry
snapshot = self.get_flow_snapshot(user_id=user_id, snapshot_key=snapshot_key)
if not snapshot:
return None
if snapshot.get("expires_at") and snapshot["expires_at"] < utc_now():
self.set_flow_snapshot(user_id=user_id, snapshot_key=snapshot_key, value=None)
return None
self.set_state_entry(bucket, user_id, snapshot)
return snapshot
def set_flow_entry(
self,
bucket: str,
user_id: int | None,
snapshot_key: str,
value: dict,
*,
active_task: str | None = None,
) -> None:
self.set_state_entry(bucket, user_id, value)
self.set_flow_snapshot(
user_id=user_id,
snapshot_key=snapshot_key,
value=value,
active_task=active_task,
)
def pop_flow_entry(
self,
bucket: str,
user_id: int | None,
snapshot_key: str,
*,
active_task: str | None = None,
) -> dict | None:
entry = self.pop_state_entry(bucket, user_id)
self.set_flow_snapshot(
user_id=user_id,
snapshot_key=snapshot_key,
value=None,
active_task=active_task,
)
return entry

@ -15,43 +15,28 @@ from app.services.orchestration.orchestrator_config import (
PENDING_ORDER_SELECTION_TTL_MINUTES,
)
from app.services.user.mock_customer_service import hydrate_mock_customer_from_cpf
from app.services.flows.order_flow_support import OrderFlowStateSupport
# Esse mixin cuida dos fluxos de venda:
# criacao de pedido, selecao de veiculo e cancelamento.
class OrderFlowMixin:
@property
def _order_flow_state_support(self) -> OrderFlowStateSupport:
support = getattr(self, "__order_flow_state_support", None)
if support is None:
support = OrderFlowStateSupport(self)
setattr(self, "__order_flow_state_support", support)
return support
def _sanitize_stock_results(self, stock_results: list[dict] | None) -> list[dict]:
sanitized: list[dict] = []
for item in stock_results or []:
if not isinstance(item, dict):
continue
try:
vehicle_id = int(item.get("id"))
preco = float(item.get("preco") or 0)
except (TypeError, ValueError):
continue
sanitized.append(
{
"id": vehicle_id,
"modelo": str(item.get("modelo") or "").strip(),
"categoria": str(item.get("categoria") or "").strip(),
"preco": preco,
"budget_relaxed": bool(item.get("budget_relaxed", False)),
}
)
return sanitized
return self._order_flow_state_support.sanitize_stock_results(stock_results)
def _get_order_flow_snapshot(self, user_id: int | None, snapshot_key: str) -> dict | None:
if user_id is None or not hasattr(self, "_get_user_context"):
return None
context = self._get_user_context(user_id)
if not isinstance(context, dict):
return None
snapshots = context.get("flow_snapshots")
if not isinstance(snapshots, dict):
return None
snapshot = snapshots.get(snapshot_key)
return dict(snapshot) if isinstance(snapshot, dict) else None
return self._order_flow_state_support.get_flow_snapshot(
user_id=user_id,
snapshot_key=snapshot_key,
)
def _set_order_flow_snapshot(
self,
@ -61,51 +46,19 @@ class OrderFlowMixin:
*,
active_task: str | None = None,
) -> None:
if user_id is None or not hasattr(self, "_get_user_context") or not hasattr(self, "_save_user_context"):
return
context = self._get_user_context(user_id)
if not isinstance(context, dict):
return
snapshots = context.get("flow_snapshots")
if not isinstance(snapshots, dict):
snapshots = {}
context["flow_snapshots"] = snapshots
if isinstance(value, dict):
snapshots[snapshot_key] = value
if active_task:
context["active_task"] = active_task
collected_slots = context.get("collected_slots")
if not isinstance(collected_slots, dict):
collected_slots = {}
context["collected_slots"] = collected_slots
payload = value.get("payload")
if isinstance(payload, dict):
collected_slots[active_task] = dict(payload)
else:
snapshots.pop(snapshot_key, None)
if active_task and context.get("active_task") == active_task:
context["active_task"] = None
collected_slots = context.get("collected_slots")
if isinstance(collected_slots, dict) and active_task:
collected_slots.pop(active_task, None)
self._save_user_context(user_id=user_id, context=context)
self._order_flow_state_support.set_flow_snapshot(
user_id=user_id,
snapshot_key=snapshot_key,
value=value,
active_task=active_task,
)
def _get_order_flow_entry(self, bucket: str, user_id: int | None, snapshot_key: str) -> dict | None:
entry = self.state.get_entry(bucket, user_id, expire=True)
if entry:
return entry
snapshot = self._get_order_flow_snapshot(user_id=user_id, snapshot_key=snapshot_key)
if not snapshot:
return None
if snapshot.get("expires_at") and snapshot["expires_at"] < utc_now():
self._set_order_flow_snapshot(user_id=user_id, snapshot_key=snapshot_key, value=None)
return None
self.state.set_entry(bucket, user_id, snapshot)
return snapshot
return self._order_flow_state_support.get_flow_entry(
bucket=bucket,
user_id=user_id,
snapshot_key=snapshot_key,
)
def _set_order_flow_entry(
self,
@ -116,8 +69,8 @@ class OrderFlowMixin:
*,
active_task: str | None = None,
) -> None:
self.state.set_entry(bucket, user_id, value)
self._set_order_flow_snapshot(
self._order_flow_state_support.set_flow_entry(
bucket=bucket,
user_id=user_id,
snapshot_key=snapshot_key,
value=value,
@ -132,14 +85,12 @@ class OrderFlowMixin:
*,
active_task: str | None = None,
) -> dict | None:
entry = self.state.pop_entry(bucket, user_id)
self._set_order_flow_snapshot(
return self._order_flow_state_support.pop_flow_entry(
bucket=bucket,
user_id=user_id,
snapshot_key=snapshot_key,
value=None,
active_task=active_task,
)
return entry
def _decision_intent(self, turn_decision: dict | None) -> str:
return str((turn_decision or {}).get("intent") or "").strip().lower()
@ -275,89 +226,40 @@ class OrderFlowMixin:
db.close()
def _get_last_stock_results(self, user_id: int | None) -> list[dict]:
pending_selection = self.state.get_entry("pending_stock_selections", user_id, expire=True)
if isinstance(pending_selection, dict):
payload = pending_selection.get("payload")
if isinstance(payload, list):
sanitized = self._sanitize_stock_results(payload)
if sanitized:
return sanitized
context = self._get_user_context(user_id)
if not context:
return []
stock_results = context.get("last_stock_results") or []
return self._sanitize_stock_results(stock_results if isinstance(stock_results, list) else [])
return self._order_flow_state_support.get_last_stock_results(user_id=user_id)
def _store_pending_stock_selection(self, user_id: int | None, stock_results: list[dict] | None) -> None:
if user_id is None:
return
sanitized = self._sanitize_stock_results(stock_results)
if not sanitized:
self.state.pop_entry("pending_stock_selections", user_id)
return
self.state.set_entry(
"pending_stock_selections",
user_id,
{
"payload": sanitized,
"expires_at": utc_now() + timedelta(minutes=PENDING_ORDER_SELECTION_TTL_MINUTES),
},
self._order_flow_state_support.store_pending_stock_selection(
user_id=user_id,
stock_results=stock_results,
)
def _get_selected_vehicle(self, user_id: int | None) -> dict | None:
context = self._get_user_context(user_id)
if not context:
return None
selected_vehicle = context.get("selected_vehicle")
return dict(selected_vehicle) if isinstance(selected_vehicle, dict) else None
return self._order_flow_state_support.get_selected_vehicle(user_id=user_id)
def _get_pending_single_vehicle_confirmation(self, user_id: int | None) -> dict | None:
context = self._get_user_context(user_id)
if not context:
return None
pending_vehicle = context.get("pending_single_vehicle_confirmation")
return dict(pending_vehicle) if isinstance(pending_vehicle, dict) else None
return self._order_flow_state_support.get_pending_single_vehicle_confirmation(user_id=user_id)
def _remember_stock_results(self, user_id: int | None, stock_results: list[dict] | None) -> None:
context = self._get_user_context(user_id)
if not context:
return
sanitized = self._sanitize_stock_results(stock_results)
context["last_stock_results"] = sanitized
self._store_pending_stock_selection(user_id=user_id, stock_results=sanitized)
if sanitized:
context["selected_vehicle"] = None
context["pending_single_vehicle_confirmation"] = None
self._save_user_context(user_id=user_id, context=context)
self._order_flow_state_support.remember_stock_results(
user_id=user_id,
stock_results=stock_results,
)
def _store_selected_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 context:
return
context["selected_vehicle"] = dict(vehicle) if isinstance(vehicle, dict) else None
context["pending_single_vehicle_confirmation"] = None
self.state.pop_entry("pending_stock_selections", user_id)
self._save_user_context(user_id=user_id, context=context)
self._order_flow_state_support.store_selected_vehicle(
user_id=user_id,
vehicle=vehicle,
)
def _store_pending_single_vehicle_confirmation(self, user_id: int | None, vehicle: dict | None) -> None:
if user_id is None:
return
context = self._get_user_context(user_id)
if not context:
return
context["pending_single_vehicle_confirmation"] = dict(vehicle) if isinstance(vehicle, dict) else None
self._save_user_context(user_id=user_id, context=context)
self._order_flow_state_support.store_pending_single_vehicle_confirmation(
user_id=user_id,
vehicle=vehicle,
)
def _clear_pending_single_vehicle_confirmation(self, user_id: int | None) -> None:
if user_id is None:
return
context = self._get_user_context(user_id)
if not isinstance(context, dict):
return
context["pending_single_vehicle_confirmation"] = None
self._save_user_context(user_id=user_id, context=context)
self._order_flow_state_support.clear_pending_single_vehicle_confirmation(user_id=user_id)
def _vehicle_to_payload(self, vehicle: dict) -> dict:
return {
@ -1192,3 +1094,4 @@ class OrderFlowMixin:
return self._fallback_format_tool_result("cancelar_pedido", tool_result)

@ -0,0 +1,117 @@
from __future__ import annotations
from datetime import timedelta
from app.core.time_utils import utc_now
from app.services.flows.flow_state_support import FlowStateSupport
from app.services.orchestration.orchestrator_config import PENDING_ORDER_SELECTION_TTL_MINUTES
class OrderFlowStateSupport(FlowStateSupport):
"""Concentra estado, snapshots e selecoes do fluxo de vendas."""
def sanitize_stock_results(self, stock_results: list[dict] | None) -> list[dict]:
sanitized: list[dict] = []
for item in stock_results or []:
if not isinstance(item, dict):
continue
try:
vehicle_id = int(item.get("id"))
preco = float(item.get("preco") or 0)
except (TypeError, ValueError):
continue
sanitized.append(
{
"id": vehicle_id,
"modelo": str(item.get("modelo") or "").strip(),
"categoria": str(item.get("categoria") or "").strip(),
"preco": preco,
"budget_relaxed": bool(item.get("budget_relaxed", False)),
}
)
return sanitized
def get_last_stock_results(self, user_id: int | None) -> list[dict]:
pending_selection = self.get_state_entry("pending_stock_selections", user_id, expire=True)
if isinstance(pending_selection, dict):
payload = pending_selection.get("payload")
if isinstance(payload, list):
sanitized = self.sanitize_stock_results(payload)
if sanitized:
return sanitized
context = self.service._get_user_context(user_id)
if not context:
return []
stock_results = context.get("last_stock_results") or []
return self.sanitize_stock_results(stock_results if isinstance(stock_results, list) else [])
def store_pending_stock_selection(self, user_id: int | None, stock_results: list[dict] | None) -> None:
if user_id is None:
return
sanitized = self.sanitize_stock_results(stock_results)
if not sanitized:
self.pop_state_entry("pending_stock_selections", user_id)
return
self.set_state_entry(
"pending_stock_selections",
user_id,
{
"payload": sanitized,
"expires_at": utc_now() + timedelta(minutes=PENDING_ORDER_SELECTION_TTL_MINUTES),
},
)
def get_selected_vehicle(self, user_id: int | None) -> dict | None:
context = self.service._get_user_context(user_id)
if not context:
return None
selected_vehicle = context.get("selected_vehicle")
return dict(selected_vehicle) if isinstance(selected_vehicle, dict) else None
def get_pending_single_vehicle_confirmation(self, user_id: int | None) -> dict | None:
context = self.service._get_user_context(user_id)
if not context:
return None
pending_vehicle = context.get("pending_single_vehicle_confirmation")
return dict(pending_vehicle) if isinstance(pending_vehicle, dict) else None
def remember_stock_results(self, user_id: int | None, stock_results: list[dict] | None) -> None:
context = self.service._get_user_context(user_id)
if not context:
return
sanitized = self.sanitize_stock_results(stock_results)
context["last_stock_results"] = sanitized
self.store_pending_stock_selection(user_id=user_id, stock_results=sanitized)
if sanitized:
context["selected_vehicle"] = None
context["pending_single_vehicle_confirmation"] = None
self.service._save_user_context(user_id=user_id, context=context)
def store_selected_vehicle(self, user_id: int | None, vehicle: dict | None) -> None:
if user_id is None:
return
context = self.service._get_user_context(user_id)
if not context:
return
context["selected_vehicle"] = dict(vehicle) if isinstance(vehicle, dict) else None
context["pending_single_vehicle_confirmation"] = None
self.pop_state_entry("pending_stock_selections", user_id)
self.service._save_user_context(user_id=user_id, context=context)
def store_pending_single_vehicle_confirmation(self, user_id: int | None, vehicle: dict | None) -> None:
if user_id is None:
return
context = self.service._get_user_context(user_id)
if not context:
return
context["pending_single_vehicle_confirmation"] = dict(vehicle) if isinstance(vehicle, dict) else None
self.service._save_user_context(user_id=user_id, context=context)
def clear_pending_single_vehicle_confirmation(self, user_id: int | None) -> None:
if user_id is None:
return
context = self.service._get_user_context(user_id)
if not isinstance(context, dict):
return
context["pending_single_vehicle_confirmation"] = None
self.service._save_user_context(user_id=user_id, context=context)

@ -0,0 +1,616 @@
import re
from datetime import datetime, timedelta
from fastapi import HTTPException
from app.core.time_utils import utc_now
from app.services.orchestration import technical_normalizer
from app.services.orchestration.orchestrator_config import (
PENDING_RENTAL_DRAFT_TTL_MINUTES,
RENTAL_REQUIRED_FIELDS,
)
from app.services.flows.rental_flow_support import RentalFlowStateSupport
class RentalFlowMixin:
@property
def _rental_flow_state_support(self) -> RentalFlowStateSupport:
support = getattr(self, "__rental_flow_state_support", None)
if support is None:
support = RentalFlowStateSupport(self)
setattr(self, "__rental_flow_state_support", support)
return support
# Sanitiza resultados da frota antes de guardar no contexto.
def _sanitize_rental_results(self, rental_results: list[dict] | None) -> list[dict]:
return self._rental_flow_state_support.sanitize_rental_results(rental_results)
# Marca locacao como dominio ativo na conversa do usuario.
def _mark_rental_flow_active(self, user_id: int | None, *, active_task: str | None = None) -> None:
self._rental_flow_state_support.mark_rental_flow_active(
user_id=user_id,
active_task=active_task,
)
# Recupera a ultima lista de veiculos disponiveis para locacao.
def _get_last_rental_results(self, user_id: int | None) -> list[dict]:
return self._rental_flow_state_support.get_last_rental_results(user_id=user_id)
# Guarda a lista atual para permitir selecao do veiculo em mensagens seguintes.
def _store_pending_rental_selection(self, user_id: int | None, rental_results: list[dict] | None) -> None:
self._rental_flow_state_support.store_pending_rental_selection(
user_id=user_id,
rental_results=rental_results,
)
# Le o veiculo de locacao escolhido que ficou salvo no contexto.
def _get_selected_rental_vehicle(self, user_id: int | None) -> dict | None:
return self._rental_flow_state_support.get_selected_rental_vehicle(user_id=user_id)
# Filtra o payload do contrato para manter so dados uteis no contexto.
def _sanitize_rental_contract_snapshot(self, payload) -> dict | None:
return self._rental_flow_state_support.sanitize_rental_contract_snapshot(payload)
# Recupera o ultimo contrato de locacao lembrado para o usuario.
def _get_last_rental_contract(self, user_id: int | None) -> dict | None:
return self._rental_flow_state_support.get_last_rental_contract(user_id=user_id)
# Atualiza o ultimo contrato de locacao salvo no contexto.
def _store_last_rental_contract(self, user_id: int | None, payload) -> None:
self._rental_flow_state_support.store_last_rental_contract(
user_id=user_id,
payload=payload,
)
# Persiste a ultima consulta de frota para reuso no fluxo incremental.
def _remember_rental_results(self, user_id: int | None, rental_results: list[dict] | None) -> None:
self._rental_flow_state_support.remember_rental_results(
user_id=user_id,
rental_results=rental_results,
)
# Salva o veiculo escolhido e encerra a etapa de selecao pendente.
def _store_selected_rental_vehicle(self, user_id: int | None, vehicle: dict | None) -> None:
self._rental_flow_state_support.store_selected_rental_vehicle(
user_id=user_id,
vehicle=vehicle,
)
# Converte um veiculo selecionado no payload esperado pela abertura da locacao.
def _rental_vehicle_to_payload(self, vehicle: dict) -> dict:
return self._rental_flow_state_support.rental_vehicle_to_payload(vehicle)
# Extrai a categoria de locacao mencionada livremente pelo usuario.
def _extract_rental_category_from_text(self, text: str) -> str | None:
normalized = self._normalize_text(text).strip()
aliases = {
"suv": "suv",
"sedan": "sedan",
"hatch": "hatch",
"pickup": "pickup",
"picape": "pickup",
}
for token, category in aliases.items():
if re.search(rf"(?<![a-z0-9]){re.escape(token)}(?![a-z0-9])", normalized):
return category
return None
# Extrai um modelo ou marca/modelo quando o pedido for mais especifico.
def _extract_rental_model_from_text(self, text: str) -> str | None:
normalized = self._normalize_text(text).strip()
if not normalized:
return None
normalized = re.sub(r"\b\d{1,2}[/-]\d{1,2}[/-]\d{4}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?\b", " ", normalized)
normalized = re.sub(r"\b\d{4}[/-]\d{1,2}[/-]\d{1,2}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?\b", " ", normalized)
normalized = re.sub(r"\b[a-z]{3}\d[a-z0-9]\d{2}\b", " ", normalized)
normalized = re.sub(r"\br\$\s*\d+[\d\.,]*\b", " ", normalized)
category = self._extract_rental_category_from_text(normalized)
if category:
normalized = re.sub(rf"(?<![a-z0-9]){re.escape(category)}(?![a-z0-9])", " ", normalized)
if category == "pickup":
normalized = re.sub(r"(?<![a-z0-9])picape(?![a-z0-9])", " ", normalized)
candidate = None
cue_patterns = (
r"(?:quero|gostaria|preciso|procuro|procurando|busco|buscando)\s+(?:alugar|locar)?\s*(?:um|uma|o|a)?\s*(?P<candidate>.+)",
r"(?:tem|ha|existe|existem|mostre|mostrar|liste|listar|quais)\s+(?:um|uma|o|a)?\s*(?P<candidate>.+)",
r"(?P<candidate>.+?)\s+(?:para\s+aluguel|para\s+locacao)\b",
)
for pattern in cue_patterns:
match = re.search(pattern, normalized)
if match:
candidate = str(match.group("candidate") or "").strip()
if candidate:
break
if not candidate:
return None
boundary_tokens = {
"para",
"pra",
"com",
"sem",
"que",
"por",
"de",
"do",
"da",
"dos",
"das",
"no",
"na",
"nos",
"nas",
"automatico",
"automatica",
"automaticos",
"automaticas",
"manual",
"manuais",
"barato",
"barata",
"economico",
"economica",
}
generic_tokens = {
"aluguel",
"alugar",
"locacao",
"locar",
"carro",
"carros",
"veiculo",
"veiculos",
"modelo",
"categoria",
"tipo",
"disponiveis",
"disponivel",
"frota",
"opcoes",
"opcao",
"esta",
"estao",
"estava",
"estavam",
"existe",
"existem",
"ha",
"tem",
"um",
"uma",
"o",
"a",
"os",
"as",
"suv",
"sedan",
"hatch",
"pickup",
"picape",
}
tokens: list[str] = []
for token in re.findall(r"[a-z0-9]+", candidate):
if token in boundary_tokens:
break
if token in generic_tokens:
continue
if re.fullmatch(r"(?:19|20)\d{2}", token):
continue
if len(token) < 2:
continue
tokens.append(token)
if len(tokens) >= 3:
break
if not tokens:
return None
return " ".join(tokens).title().strip() or None
# Coleta datas de locacao em texto livre mantendo a ordem encontrada.
def _extract_rental_datetimes_from_text(self, text: str) -> list[str]:
normalized = technical_normalizer.normalize_datetime_connector(text)
patterns = (
r"\b\d{1,2}[/-]\d{1,2}[/-]\d{4}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?\b",
r"\b\d{4}[/-]\d{1,2}[/-]\d{1,2}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?\b",
)
results: list[str] = []
for pattern in patterns:
for match in re.finditer(pattern, normalized):
candidate = self._normalize_rental_datetime_text(match.group(0))
if candidate and candidate not in results:
results.append(candidate)
return results
# Normaliza datas de locacao para um formato unico aceito pelo fluxo.
def _normalize_rental_datetime_text(self, value) -> str | None:
text = technical_normalizer.normalize_datetime_connector(str(value or "").strip())
if not text:
return None
parsed = technical_normalizer.try_parse_iso_datetime(text)
if parsed is None:
parsed = technical_normalizer.try_parse_datetime_with_formats(
text,
(
"%d/%m/%Y %H:%M",
"%d/%m/%Y %H:%M:%S",
"%d/%m/%Y",
"%Y-%m-%d %H:%M",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%d",
),
)
if parsed is None:
return None
if ":" in text:
return parsed.strftime("%d/%m/%Y %H:%M")
return parsed.strftime("%d/%m/%Y")
# Normaliza campos estruturados de aluguel antes de montar o draft.
def _normalize_rental_fields(self, data) -> dict:
if not isinstance(data, dict):
return {}
payload: dict = {}
rental_vehicle_id = data.get("rental_vehicle_id")
if rental_vehicle_id is None:
rental_vehicle_id = data.get("vehicle_id")
try:
if rental_vehicle_id not in (None, ""):
numeric = int(rental_vehicle_id)
if numeric > 0:
payload["rental_vehicle_id"] = numeric
except (TypeError, ValueError):
pass
placa = technical_normalizer.normalize_plate(data.get("placa"))
if placa:
payload["placa"] = placa
cpf = technical_normalizer.normalize_cpf(data.get("cpf"))
if cpf:
payload["cpf"] = cpf
valor_diaria_max = technical_normalizer.normalize_positive_number(data.get("valor_diaria_max"))
if valor_diaria_max:
payload["valor_diaria_max"] = float(valor_diaria_max)
categoria = self._extract_rental_category_from_text(str(data.get("categoria") or ""))
if categoria:
payload["categoria"] = categoria
model_hint = str(data.get("modelo") or data.get("modelo_veiculo") or "").strip(" ,.;")
if model_hint and not self._extract_rental_category_from_text(model_hint):
payload["modelo"] = model_hint.title()
for field_name in ("data_inicio", "data_fim_prevista"):
normalized = self._normalize_rental_datetime_text(data.get(field_name))
if normalized:
payload[field_name] = normalized
return payload
# Enriquece o draft com placa, cpf, categoria, budget e datas extraidos da mensagem.
def _try_capture_rental_fields_from_message(self, message: str, payload: dict) -> None:
if payload.get("placa") is None:
words = re.findall(r"[A-Za-z0-9-]+", str(message or ""))
for word in words:
plate = technical_normalizer.normalize_plate(word)
if plate:
payload["placa"] = plate
break
if payload.get("cpf") is None:
cpf = technical_normalizer.extract_cpf_from_text(message)
if cpf and technical_normalizer.is_valid_cpf(cpf):
payload["cpf"] = cpf
if payload.get("categoria") is None:
category = self._extract_rental_category_from_text(message)
if category:
payload["categoria"] = category
if payload.get("valor_diaria_max") is None:
budget = technical_normalizer.extract_budget_from_text(message)
if budget:
payload["valor_diaria_max"] = float(budget)
if payload.get("modelo") is None:
model_hint = self._extract_rental_model_from_text(message)
if model_hint:
payload["modelo"] = model_hint
datetimes = self._extract_rental_datetimes_from_text(message)
if datetimes:
if not payload.get("data_inicio"):
payload["data_inicio"] = datetimes[0]
if len(datetimes) >= 2 and not payload.get("data_fim_prevista"):
payload["data_fim_prevista"] = datetimes[1]
elif len(datetimes) == 1 and payload.get("data_inicio") and not payload.get("data_fim_prevista"):
if payload["data_inicio"] != datetimes[0]:
payload["data_fim_prevista"] = datetimes[0]
# Detecta pedidos para listar a frota de aluguel.
def _has_rental_listing_request(self, message: str, turn_decision: dict | None = None) -> bool:
decision_intent = self._decision_intent(turn_decision)
decision_domain = str((turn_decision or {}).get("domain") or "").strip().lower()
if decision_domain == "rental" and decision_intent in {"rental_list", "rental_search"}:
return True
normalized = self._normalize_text(message).strip()
rental_terms = {"aluguel", "alugar", "locacao", "locar"}
listing_terms = {"quais", "listar", "liste", "mostrar", "mostre", "disponiveis", "disponivel", "frota", "opcoes", "opcao"}
return any(term in normalized for term in rental_terms) and any(term in normalized for term in listing_terms)
# Detecta quando o usuario quer iniciar uma nova locacao.
def _has_explicit_rental_request(self, message: str) -> bool:
normalized = self._normalize_text(message).strip()
if any(term in normalized for term in {"multa", "comprovante", "pagamento", "devolucao", "devolver"}):
return False
request_terms = {
"quero alugar",
"quero locar",
"abrir locacao",
"abrir aluguel",
"fazer locacao",
"iniciar locacao",
"seguir com a locacao",
"seguir com aluguel",
"alugar o carro",
"locacao do carro",
}
return any(term in normalized for term in request_terms)
# Detecta pedidos de devolucao ou encerramento da locacao.
def _has_rental_return_request(self, message: str) -> bool:
normalized = self._normalize_text(message).strip()
return any(term in normalized for term in {"devolver", "devolucao", "encerrar locacao", "fechar locacao"})
# Detecta quando a mensagem parece tratar de pagamento ou multa de aluguel.
def _has_rental_payment_or_fine_request(self, message: str) -> bool:
normalized = self._normalize_text(message).strip()
return any(term in normalized for term in {"multa", "comprovante", "pagamento", "boleto", "pix"})
# Interpreta selecoes numericas com base na ultima lista apresentada.
def _match_rental_vehicle_from_message_index(self, message: str, rental_results: list[dict]) -> dict | None:
tokens = [token for token in re.findall(r"\d+", str(message or "")) if token.isdigit()]
if not tokens:
return None
choice = int(tokens[0])
if 1 <= choice <= len(rental_results):
return rental_results[choice - 1]
return None
# Tenta casar a resposta do usuario com modelo ou placa da frota mostrada.
def _match_rental_vehicle_from_message_model(self, message: str, rental_results: list[dict]) -> dict | None:
normalized_message = self._normalize_text(message)
matches = []
for item in rental_results:
normalized_model = self._normalize_text(str(item.get("modelo") or ""))
normalized_plate = self._normalize_text(str(item.get("placa") or ""))
if (normalized_model and normalized_model in normalized_message) or (
normalized_plate and normalized_plate in normalized_message
):
matches.append(item)
if len(matches) == 1:
return matches[0]
return None
# Resolve o veiculo escolhido reaproveitando contexto e texto livre.
def _try_resolve_rental_vehicle(self, message: str, user_id: int | None, payload: dict) -> dict | None:
rental_vehicle_id = payload.get("rental_vehicle_id")
if isinstance(rental_vehicle_id, int) and rental_vehicle_id > 0:
for item in self._get_last_rental_results(user_id=user_id):
if int(item.get("id") or 0) == rental_vehicle_id:
return item
rental_results = self._get_last_rental_results(user_id=user_id)
selected_from_model = self._match_rental_vehicle_from_message_model(message=message, rental_results=rental_results)
if selected_from_model:
return selected_from_model
selected_from_index = self._match_rental_vehicle_from_message_index(message=message, rental_results=rental_results)
if selected_from_index:
return selected_from_index
normalized_plate = technical_normalizer.normalize_plate(payload.get("placa"))
if normalized_plate:
matches = [item for item in rental_results if str(item.get("placa") or "").strip().upper() == normalized_plate]
if len(matches) == 1:
return matches[0]
return None
# Decide se a mensagem atual pode continuar uma selecao de aluguel ja iniciada.
def _should_bootstrap_rental_from_context(self, message: str, user_id: int | None, payload: dict | None = None) -> bool:
if user_id is None:
return False
rental_results = self._get_last_rental_results(user_id=user_id)
if not rental_results:
return False
normalized_payload = payload if isinstance(payload, dict) else {}
return bool(
self._match_rental_vehicle_from_message_model(message=message, rental_results=rental_results)
or self._match_rental_vehicle_from_message_index(message=message, rental_results=rental_results)
or (
normalized_payload.get("placa")
and self._try_resolve_rental_vehicle(message=message, user_id=user_id, payload=normalized_payload)
)
)
# Monta a pergunta objetiva com os campos que faltam para abrir a locacao.
def _render_missing_rental_fields_prompt(self, missing_fields: list[str]) -> str:
labels = {
"rental_vehicle_id": "qual veiculo da frota voce quer alugar",
"data_inicio": "a data e hora de inicio da locacao",
"data_fim_prevista": "a data e hora previstas para devolucao",
}
items = [f"- {labels[field]}" for field in missing_fields]
return "Para abrir a locacao, preciso dos dados abaixo:\n" + "\n".join(items)
# Formata a lista curta da frota para o usuario escolher um veiculo.
def _render_rental_selection_from_fleet_prompt(self, rental_results: list[dict]) -> str:
lines = ["Para seguir com a locacao, escolha primeiro qual veiculo voce quer alugar:"]
for idx, item in enumerate(rental_results[:10], start=1):
lines.append(
f"- {idx}. {item.get('modelo', 'N/A')} {item.get('ano', 'N/A')} | "
f"{item.get('placa', 'N/A')} | {item.get('categoria', 'N/A')} | "
f"diaria R$ {float(item.get('valor_diaria', 0)):.2f}"
)
lines.append("Pode responder com o numero da lista, com a placa ou com o modelo.")
return "\n".join(lines)
# Consulta a frota e guarda o resultado para a etapa de selecao.
async def _try_list_rental_fleet_for_selection(
self,
message: str,
user_id: int | None,
payload: dict,
turn_decision: dict | None = None,
force: bool = False,
) -> str | None:
if user_id is None:
return None
if not force and not self._has_rental_listing_request(message, turn_decision=turn_decision):
return None
arguments: dict = {
"limite": 10,
}
category = payload.get("categoria") or self._extract_rental_category_from_text(message)
if category:
arguments["categoria"] = str(category).strip().lower()
model_hint = str(payload.get("modelo") or self._extract_rental_model_from_text(message) or "").strip()
if model_hint:
arguments["modelo"] = model_hint
arguments["ordenar_diaria"] = "asc" if (category or model_hint) else "random"
valor_diaria_max = payload.get("valor_diaria_max")
if not isinstance(valor_diaria_max, (int, float)):
valor_diaria_max = technical_normalizer.extract_budget_from_text(message)
if isinstance(valor_diaria_max, (int, float)) and float(valor_diaria_max) > 0:
arguments["valor_diaria_max"] = float(valor_diaria_max)
try:
tool_result = await self.tool_executor.execute(
"consultar_frota_aluguel",
arguments,
user_id=user_id,
)
except HTTPException as exc:
return self._http_exception_detail(exc)
rental_results = tool_result if isinstance(tool_result, list) else []
self._remember_rental_results(user_id=user_id, rental_results=rental_results)
self._mark_rental_flow_active(user_id=user_id)
return self._fallback_format_tool_result("consultar_frota_aluguel", tool_result)
# Conduz a coleta incremental dos dados e abre a locacao quando estiver completa.
async def _try_collect_and_open_rental(
self,
message: str,
user_id: int | None,
extracted_fields: dict | None = None,
intents: dict | None = None,
turn_decision: dict | None = None,
) -> str | None:
if user_id is None:
return None
draft = self.state.get_entry("pending_rental_drafts", user_id, expire=True)
extracted = self._normalize_rental_fields(extracted_fields)
decision_intent = self._decision_intent(turn_decision)
has_intent = decision_intent in {"rental_create", "rental_list", "rental_search"}
explicit_rental_request = self._has_explicit_rental_request(message)
rental_listing_request = self._has_rental_listing_request(message, turn_decision=turn_decision)
should_bootstrap_from_context = draft is None and self._should_bootstrap_rental_from_context(
message=message,
user_id=user_id,
payload=extracted,
)
if (
draft is None
and not has_intent
and not explicit_rental_request
and not rental_listing_request
and not should_bootstrap_from_context
):
return None
if draft is None:
draft = {
"payload": {},
"expires_at": utc_now() + timedelta(minutes=PENDING_RENTAL_DRAFT_TTL_MINUTES),
}
draft_payload = draft.get("payload", {})
if not isinstance(draft_payload, dict):
draft_payload = {}
draft["payload"] = draft_payload
draft_payload.update(extracted)
self._try_capture_rental_fields_from_message(message=message, payload=draft_payload)
selected_vehicle = self._get_selected_rental_vehicle(user_id=user_id)
if selected_vehicle and not draft_payload.get("rental_vehicle_id"):
draft_payload.update(self._rental_vehicle_to_payload(selected_vehicle))
resolved_vehicle = self._try_resolve_rental_vehicle(
message=message,
user_id=user_id,
payload=draft_payload,
)
if resolved_vehicle:
self._store_selected_rental_vehicle(user_id=user_id, vehicle=resolved_vehicle)
draft_payload.update(self._rental_vehicle_to_payload(resolved_vehicle))
draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_RENTAL_DRAFT_TTL_MINUTES)
self.state.set_entry("pending_rental_drafts", user_id, draft)
self._mark_rental_flow_active(user_id=user_id, active_task="rental_create")
missing = [field for field in RENTAL_REQUIRED_FIELDS if field not in draft_payload]
if missing:
if "rental_vehicle_id" in missing:
fleet_response = await self._try_list_rental_fleet_for_selection(
message=message,
user_id=user_id,
payload=draft_payload,
turn_decision=turn_decision,
force=bool(draft) or explicit_rental_request or rental_listing_request or should_bootstrap_from_context,
)
if fleet_response:
return fleet_response
rental_results = self._get_last_rental_results(user_id=user_id)
if rental_results:
return self._render_rental_selection_from_fleet_prompt(rental_results)
return self._render_missing_rental_fields_prompt(missing)
try:
tool_result = await self.tool_executor.execute(
"abrir_locacao_aluguel",
{
"rental_vehicle_id": draft_payload["rental_vehicle_id"],
"placa": draft_payload.get("placa"),
"data_inicio": draft_payload["data_inicio"],
"data_fim_prevista": draft_payload["data_fim_prevista"],
"cpf": draft_payload.get("cpf"),
},
user_id=user_id,
)
except HTTPException as exc:
return self._http_exception_detail(exc)
self._store_last_rental_contract(user_id=user_id, payload=tool_result)
self._reset_pending_rental_states(user_id=user_id)
return self._fallback_format_tool_result("abrir_locacao_aluguel", tool_result)

@ -0,0 +1,310 @@
from __future__ import annotations
from datetime import timedelta
from sqlalchemy import or_
from app.core.time_utils import utc_now
from app.db.mock_database import SessionMockLocal
from app.db.mock_models import RentalContract, RentalFine, RentalPayment
from app.services.flows.flow_state_support import FlowStateSupport
from app.services.orchestration import technical_normalizer
from app.services.orchestration.orchestrator_config import PENDING_RENTAL_SELECTION_TTL_MINUTES
class RentalFlowStateSupport(FlowStateSupport):
"""Concentra estado e contexto incremental do fluxo de locacao."""
def _load_last_rental_contract_snapshot(self, user_id: int | None) -> dict | None:
if user_id is None:
return None
db = None
try:
db = SessionMockLocal()
base_query = db.query(RentalContract).filter(RentalContract.user_id == user_id)
contract = (
base_query.filter(RentalContract.status == "ativa")
.order_by(RentalContract.created_at.desc())
.first()
)
if contract is None:
contract = base_query.order_by(RentalContract.created_at.desc()).first()
if contract is None:
return None
payload = {
"contrato_numero": contract.contrato_numero,
"placa": contract.placa,
"modelo_veiculo": contract.modelo_veiculo,
"categoria": contract.categoria,
"data_inicio": contract.data_inicio.isoformat() if contract.data_inicio else None,
"data_fim_prevista": contract.data_fim_prevista.isoformat() if contract.data_fim_prevista else None,
"data_devolucao": contract.data_devolucao.isoformat() if contract.data_devolucao else None,
"valor_diaria": contract.valor_diaria,
"valor_previsto": contract.valor_previsto,
"valor_final": contract.valor_final,
"status": contract.status,
}
latest_payment = (
db.query(RentalPayment)
.filter(
or_(
RentalPayment.rental_contract_id == contract.id,
RentalPayment.contrato_numero == contract.contrato_numero,
)
)
.order_by(RentalPayment.created_at.desc())
.first()
)
if latest_payment is not None:
payload.update(
{
"valor": latest_payment.valor,
"data_pagamento": latest_payment.data_pagamento.isoformat()
if latest_payment.data_pagamento
else None,
"favorecido": latest_payment.favorecido,
"status": "registrado",
}
)
latest_fine = (
db.query(RentalFine)
.filter(
or_(
RentalFine.rental_contract_id == contract.id,
RentalFine.contrato_numero == contract.contrato_numero,
)
)
.order_by(RentalFine.created_at.desc())
.first()
)
if latest_fine is not None:
payload.update(
{
"auto_infracao": latest_fine.auto_infracao,
"data_infracao": latest_fine.data_infracao.isoformat()
if latest_fine.data_infracao
else None,
"vencimento": latest_fine.vencimento.isoformat() if latest_fine.vencimento else None,
}
)
if latest_fine.valor is not None:
payload["valor_multa"] = float(latest_fine.valor)
return self.sanitize_rental_contract_snapshot(payload)
except Exception:
return None
finally:
if db is not None:
try:
db.close()
except Exception:
pass
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.service._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.service._save_user_context(user_id=user_id, context=context)
def get_last_rental_results(self, user_id: int | None) -> list[dict]:
pending_selection = self.get_state_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.service._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.pop_state_entry("pending_rental_selections", user_id)
return
self.set_state_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.service._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 sanitize_rental_contract_snapshot(self, payload) -> dict | None:
if not isinstance(payload, dict):
return None
contract_number = str(payload.get("contrato_numero") or "").strip().upper()
plate = technical_normalizer.normalize_plate(payload.get("placa"))
if not contract_number and not plate:
return None
snapshot: dict = {}
if contract_number:
snapshot["contrato_numero"] = contract_number
if plate:
snapshot["placa"] = plate
for field_name in (
"modelo_veiculo",
"categoria",
"status_veiculo",
"data_inicio",
"data_fim_prevista",
"data_devolucao",
):
value = str(payload.get(field_name) or "").strip()
if value:
snapshot[field_name] = value
status_value = str(payload.get("status") or "").strip()
if status_value:
if payload.get("data_pagamento"):
snapshot["status_pagamento"] = status_value
else:
snapshot["status"] = status_value
for field_name in ("valor_diaria", "valor_previsto", "valor_final"):
number = technical_normalizer.normalize_positive_number(payload.get(field_name))
if number is not None:
snapshot[field_name] = float(number)
payment_date = str(payload.get("data_pagamento") or "").strip()
if payment_date:
snapshot["data_pagamento"] = payment_date
payment_value = technical_normalizer.normalize_positive_number(payload.get("valor"))
if payment_value is not None:
snapshot["valor_pagamento"] = float(payment_value)
favorecido = str(payload.get("favorecido") or "").strip()
if favorecido:
snapshot["favorecido"] = favorecido
snapshot.setdefault("status_pagamento", "registrado")
violation_date = str(payload.get("data_infracao") or "").strip()
if violation_date:
snapshot["data_infracao"] = violation_date
due_date = str(payload.get("vencimento") or "").strip()
if due_date:
snapshot["vencimento"] = due_date
infraction_notice = str(payload.get("auto_infracao") or "").strip()
if infraction_notice:
snapshot["auto_infracao"] = infraction_notice
if violation_date or infraction_notice:
fine_value = technical_normalizer.normalize_positive_number(payload.get("valor"))
if fine_value is not None:
snapshot["valor_multa"] = float(fine_value)
return snapshot
def get_last_rental_contract(self, user_id: int | None) -> dict | None:
context = self.service._get_user_context(user_id)
if isinstance(context, dict):
contract = context.get("last_rental_contract")
if isinstance(contract, dict):
return dict(contract)
snapshot = self._load_last_rental_contract_snapshot(user_id=user_id)
if snapshot and isinstance(context, dict):
context["last_rental_contract"] = dict(snapshot)
self.service._save_user_context(user_id=user_id, context=context)
return dict(snapshot) if isinstance(snapshot, dict) else None
def store_last_rental_contract(self, user_id: int | None, payload) -> None:
if user_id is None:
return
context = self.service._get_user_context(user_id)
if not isinstance(context, dict):
return
sanitized = self.sanitize_rental_contract_snapshot(payload)
if sanitized is None:
context.pop("last_rental_contract", None)
else:
existing = context.get("last_rental_contract")
merged = dict(existing) if isinstance(existing, dict) else {}
merged.update(sanitized)
if merged.get("data_pagamento") and not merged.get("status_pagamento"):
merged["status_pagamento"] = "registrado"
elif merged.get("contrato_numero") and not merged.get("data_devolucao") and not merged.get("status_pagamento"):
merged["status_pagamento"] = "em aberto"
context["last_rental_contract"] = merged
self.service._save_user_context(user_id=user_id, context=context)
def remember_rental_results(self, user_id: int | None, rental_results: list[dict] | None) -> None:
context = self.service._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.service._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.service._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.pop_state_entry("pending_rental_selections", user_id)
self.service._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),
}

@ -5,31 +5,30 @@ from app.core.time_utils import utc_now
from fastapi import HTTPException
from app.services.orchestration.orchestrator_config import (
LAST_REVIEW_PACKAGE_TTL_MINUTES,
PENDING_REVIEW_DRAFT_TTL_MINUTES,
REVIEW_REQUIRED_FIELDS,
)
from app.services.flows.review_flow_support import ReviewFlowStateSupport
# Esse mixin concentra os fluxos incrementais de revisao e pos-venda.
class ReviewFlowMixin:
@property
def _review_flow_state_support(self) -> ReviewFlowStateSupport:
support = getattr(self, "__review_flow_state_support", None)
if support is None:
support = ReviewFlowStateSupport(self)
setattr(self, "__review_flow_state_support", support)
return support
def _review_now(self) -> datetime:
provider = getattr(self, "_review_now_provider", None)
if callable(provider):
return provider()
return datetime.now()
return self._review_flow_state_support.review_now()
def _get_review_flow_snapshot(self, user_id: int | None, snapshot_key: str) -> dict | None:
if user_id is None or not hasattr(self, "_get_user_context"):
return None
context = self._get_user_context(user_id)
if not isinstance(context, dict):
return None
snapshots = context.get("flow_snapshots")
if not isinstance(snapshots, dict):
return None
snapshot = snapshots.get(snapshot_key)
return dict(snapshot) if isinstance(snapshot, dict) else None
return self._review_flow_state_support.get_flow_snapshot(
user_id=user_id,
snapshot_key=snapshot_key,
)
def _set_review_flow_snapshot(
self,
@ -39,51 +38,19 @@ class ReviewFlowMixin:
*,
active_task: str | None = None,
) -> None:
if user_id is None or not hasattr(self, "_get_user_context") or not hasattr(self, "_save_user_context"):
return
context = self._get_user_context(user_id)
if not isinstance(context, dict):
return
snapshots = context.get("flow_snapshots")
if not isinstance(snapshots, dict):
snapshots = {}
context["flow_snapshots"] = snapshots
if isinstance(value, dict):
snapshots[snapshot_key] = value
if active_task:
context["active_task"] = active_task
collected_slots = context.get("collected_slots")
if not isinstance(collected_slots, dict):
collected_slots = {}
context["collected_slots"] = collected_slots
payload = value.get("payload")
if isinstance(payload, dict):
collected_slots[active_task] = dict(payload)
else:
snapshots.pop(snapshot_key, None)
if active_task and context.get("active_task") == active_task:
context["active_task"] = None
collected_slots = context.get("collected_slots")
if isinstance(collected_slots, dict) and active_task:
collected_slots.pop(active_task, None)
self._save_user_context(user_id=user_id, context=context)
self._review_flow_state_support.set_flow_snapshot(
user_id=user_id,
snapshot_key=snapshot_key,
value=value,
active_task=active_task,
)
def _get_review_flow_entry(self, bucket: str, user_id: int | None, snapshot_key: str) -> dict | None:
entry = self.state.get_entry(bucket, user_id, expire=True)
if entry:
return entry
snapshot = self._get_review_flow_snapshot(user_id=user_id, snapshot_key=snapshot_key)
if not snapshot:
return None
if snapshot.get("expires_at") and snapshot["expires_at"] < utc_now():
self._set_review_flow_snapshot(user_id=user_id, snapshot_key=snapshot_key, value=None)
return None
self.state.set_entry(bucket, user_id, snapshot)
return snapshot
return self._review_flow_state_support.get_flow_entry(
bucket=bucket,
user_id=user_id,
snapshot_key=snapshot_key,
)
def _set_review_flow_entry(
self,
@ -94,8 +61,8 @@ class ReviewFlowMixin:
*,
active_task: str | None = None,
) -> None:
self.state.set_entry(bucket, user_id, value)
self._set_review_flow_snapshot(
self._review_flow_state_support.set_flow_entry(
bucket=bucket,
user_id=user_id,
snapshot_key=snapshot_key,
value=value,
@ -110,14 +77,12 @@ class ReviewFlowMixin:
*,
active_task: str | None = None,
) -> dict | None:
entry = self.state.pop_entry(bucket, user_id)
self._set_review_flow_snapshot(
return self._review_flow_state_support.pop_flow_entry(
bucket=bucket,
user_id=user_id,
snapshot_key=snapshot_key,
value=None,
active_task=active_task,
)
return entry
def _decision_intent(self, turn_decision: dict | None) -> str:
return str((turn_decision or {}).get("intent") or "").strip().lower()
@ -128,22 +93,14 @@ class ReviewFlowMixin:
payload: dict | None = None,
missing_fields: list[str] | None = None,
) -> None:
if not hasattr(self, "_log_turn_event"):
return
self._log_turn_event(
"review_flow_progress",
review_flow_source=source,
payload_keys=sorted((payload or {}).keys()),
missing_fields=list(missing_fields or []),
self._review_flow_state_support.log_review_flow_source(
source=source,
payload=payload,
missing_fields=missing_fields,
)
def _active_domain(self, user_id: int | None) -> str:
if user_id is None or not hasattr(self, "_get_user_context"):
return "general"
context = self._get_user_context(user_id)
if not isinstance(context, dict):
return "general"
return str(context.get("active_domain") or "general").strip().lower()
return self._review_flow_state_support.active_domain(user_id=user_id)
def _clean_review_model_candidate(self, raw_model: str | None) -> str | None:
text = str(raw_model or "").strip(" ,.;:-")
@ -659,38 +616,13 @@ class ReviewFlowMixin:
)
def _store_last_review_package(self, user_id: int | None, payload: dict | None) -> None:
if user_id is None or not isinstance(payload, dict):
return
# Guarda um pacote reutilizavel do ultimo veiculo informado
# para reduzir repeticao em novos agendamentos.
package = {
"placa": payload.get("placa"),
"modelo": payload.get("modelo"),
"ano": payload.get("ano"),
"km": payload.get("km"),
"revisao_previa_concessionaria": payload.get("revisao_previa_concessionaria"),
}
sanitized = {k: v for k, v in package.items() if v is not None}
required = {"placa", "modelo", "ano", "km", "revisao_previa_concessionaria"}
if not required.issubset(sanitized.keys()):
return
self.state.set_entry(
"last_review_packages",
user_id,
{
"payload": sanitized,
"expires_at": utc_now() + timedelta(minutes=LAST_REVIEW_PACKAGE_TTL_MINUTES),
},
self._review_flow_state_support.store_last_review_package(
user_id=user_id,
payload=payload,
)
def _get_last_review_package(self, user_id: int | None) -> dict | None:
if user_id is None:
return None
cached = self.state.get_entry("last_review_packages", user_id, expire=True)
if not cached:
return None
payload = cached.get("payload")
return dict(payload) if isinstance(payload, dict) else None
return self._review_flow_state_support.get_last_review_package(user_id=user_id)
async def _try_collect_and_schedule_review(
self,
@ -968,3 +900,4 @@ class ReviewFlowMixin:
self._store_last_review_package(user_id=user_id, payload=draft["payload"])
self._log_review_flow_source(source=review_flow_source or "draft", payload=draft["payload"])
return self._fallback_format_tool_result("agendar_revisao", tool_result)

@ -0,0 +1,72 @@
from __future__ import annotations
from datetime import datetime, timedelta
from app.core.time_utils import utc_now
from app.services.flows.flow_state_support import FlowStateSupport
from app.services.orchestration.orchestrator_config import LAST_REVIEW_PACKAGE_TTL_MINUTES
class ReviewFlowStateSupport(FlowStateSupport):
"""Concentra estado e utilitarios de suporte do fluxo de revisao."""
def review_now(self) -> datetime:
provider = getattr(self.service, "_review_now_provider", None)
if callable(provider):
return provider()
return datetime.now()
def log_review_flow_source(
self,
source: str,
payload: dict | None = None,
missing_fields: list[str] | None = None,
) -> None:
if not hasattr(self.service, "_log_turn_event"):
return
self.service._log_turn_event(
"review_flow_progress",
review_flow_source=source,
payload_keys=sorted((payload or {}).keys()),
missing_fields=list(missing_fields or []),
)
def active_domain(self, user_id: int | None) -> str:
if user_id is None or not hasattr(self.service, "_get_user_context"):
return "general"
context = self.service._get_user_context(user_id)
if not isinstance(context, dict):
return "general"
return str(context.get("active_domain") or "general").strip().lower()
def store_last_review_package(self, user_id: int | None, payload: dict | None) -> None:
if user_id is None or not isinstance(payload, dict):
return
package = {
"placa": payload.get("placa"),
"modelo": payload.get("modelo"),
"ano": payload.get("ano"),
"km": payload.get("km"),
"revisao_previa_concessionaria": payload.get("revisao_previa_concessionaria"),
}
sanitized = {key: value for key, value in package.items() if value is not None}
required = {"placa", "modelo", "ano", "km", "revisao_previa_concessionaria"}
if not required.issubset(sanitized.keys()):
return
self.set_state_entry(
"last_review_packages",
user_id,
{
"payload": sanitized,
"expires_at": utc_now() + timedelta(minutes=LAST_REVIEW_PACKAGE_TTL_MINUTES),
},
)
def get_last_review_package(self, user_id: int | None) -> dict | None:
if user_id is None:
return None
cached = self.get_state_entry("last_review_packages", user_id, expire=True)
if not cached:
return None
payload = cached.get("payload")
return dict(payload) if isinstance(payload, dict) else None

@ -5,6 +5,7 @@ from typing import Any
from app.db.mock_database import SessionMockLocal
from app.db.mock_models import ConversationTurn, User
from app.services.orchestration.sensitive_data import mask_sensitive_payload, mask_sensitive_text
logger = logging.getLogger(__name__)
@ -49,17 +50,17 @@ class ConversationHistoryService:
"conversation_id": str(conversation_id or "anonymous"),
"user_id": user_id,
"channel": channel,
"external_id": external_id,
"external_id": mask_sensitive_payload(external_id, key="external_id"),
"username": username,
"user_message": str(user_message or ""),
"assistant_response": assistant_response,
"user_message": mask_sensitive_text(str(user_message or "")),
"assistant_response": mask_sensitive_text(assistant_response),
"turn_status": str(turn_status or "completed"),
"intent": self._clean_text(intent),
"domain": self._clean_text(domain),
"action": self._clean_text(action),
"tool_name": self._clean_text(tool_name),
"tool_arguments": self._serialize_json(tool_arguments),
"error_detail": error_detail,
"tool_arguments": self._serialize_json(mask_sensitive_payload(tool_arguments)),
"error_detail": mask_sensitive_text(error_detail),
}
if started_at is not None:
payload["started_at"] = started_at
@ -133,17 +134,17 @@ class ConversationHistoryService:
"conversation_id": row.conversation_id,
"user_id": row.user_id,
"channel": row.channel,
"external_id": row.external_id,
"external_id": mask_sensitive_payload(row.external_id, key="external_id"),
"username": row.username,
"user_message": row.user_message,
"assistant_response": row.assistant_response,
"user_message": mask_sensitive_text(row.user_message),
"assistant_response": mask_sensitive_text(row.assistant_response),
"turn_status": row.turn_status,
"intent": row.intent,
"domain": row.domain,
"action": row.action,
"tool_name": row.tool_name,
"tool_arguments": self._deserialize_json(row.tool_arguments),
"error_detail": row.error_detail,
"tool_arguments": mask_sensitive_payload(self._deserialize_json(row.tool_arguments)),
"error_detail": mask_sensitive_text(row.error_detail),
"elapsed_ms": row.elapsed_ms,
"started_at": row.started_at.isoformat() if row.started_at else None,
"completed_at": row.completed_at.isoformat() if row.completed_at else None,

@ -66,6 +66,10 @@ CONTEXT_FIELD_LABELS = {
"modelo_veiculo": "modelo_veiculo",
"valor_veiculo": "valor_veiculo",
"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 = {
@ -73,8 +77,11 @@ ACTIVE_TASK_LABELS = {
"review_management": "gestao de revisao",
"order_create": "criacao de pedido",
"order_cancel": "cancelamento de pedido",
"rental_create": "abertura de locacao",
}
ACTIONABLE_ORDER_DOMAINS = {"review", "sales", "rental"}
# 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...
class ConversationPolicy:
def __init__(self, service: "OrquestradorService"):
@ -127,20 +134,21 @@ class ConversationPolicy:
domain: str,
order_message: str,
memory_seed: dict | None = None,
) -> None:
) -> bool:
context = self.service._get_user_context(user_id)
if not context or domain == "general":
return
return False
queue = context.setdefault("order_queue", [])
queue.append(
{
"domain": domain,
"message": (order_message or "").strip(),
"message": self.build_order_execution_message(domain, order_message),
"memory_seed": dict(memory_seed or self.service._new_tab_memory(user_id=user_id)),
"created_at": utc_now().isoformat(),
}
)
self._save_context(user_id=user_id, context=context)
return True
# Transforma as entidades extraídas de um pedido em uma memória temporária pronta para usar quando esse pedido for processado.
@ -205,7 +213,7 @@ class ConversationPolicy:
if not isinstance(item, dict):
continue
domain = str(item.get("domain") or "general").strip().lower()
if domain not in {"review", "sales", "general"}:
if domain not in ACTIONABLE_ORDER_DOMAINS | {"general"}:
domain = "general"
segment = str(item.get("message") or "").strip()
if segment:
@ -218,14 +226,19 @@ class ConversationPolicy:
)
if not extracted_orders:
extracted_orders = [{"domain": "general", "message": (message or "").strip()}]
extracted_orders = self.augment_actionable_orders_from_message(
message=message,
extracted_orders=extracted_orders,
)
actionable_orders = [order for order in extracted_orders if order["domain"] in ACTIONABLE_ORDER_DOMAINS]
if (
len(extracted_orders) == 2
and all(order["domain"] != "general" for order in extracted_orders)
len(actionable_orders) >= 2
and not self.has_open_flow(user_id=user_id, domain=active_domain)
):
self.store_pending_order_selection(user_id=user_id, orders=extracted_orders)
return message, None, self.render_order_selection_prompt(extracted_orders)
self.store_pending_order_selection(user_id=user_id, orders=actionable_orders)
return message, None, self.render_order_selection_prompt(actionable_orders)
if len(extracted_orders) <= 1:
inferred = extracted_orders[0]["domain"]
@ -242,29 +255,33 @@ class ConversationPolicy:
if self.has_open_flow(user_id=user_id, domain=active_domain):
queued_count = 0
for queued in extracted_orders:
for queued in actionable_orders:
if queued["domain"] != active_domain:
queued_count += int(
self.queue_order_with_memory_seed(
user_id=user_id,
domain=queued["domain"],
order_message=queued["message"],
memory_seed=self.build_order_memory_seed(user_id=user_id, order=queued),
)
queued_count += 1
)
queue_hint = self.render_queue_notice(queued_count)
prompt = self.render_open_flow_prompt(user_id=user_id, domain=active_domain)
return message, None, f"{prompt}\n{queue_hint}" if queue_hint else prompt
first = extracted_orders[0]
first = actionable_orders[0] if actionable_orders else extracted_orders[0]
queued_count = 0
for queued in extracted_orders[1:]:
for queued in actionable_orders:
if queued is first:
continue
queued_count += int(
self.queue_order_with_memory_seed(
user_id=user_id,
domain=queued["domain"],
order_message=queued["message"],
memory_seed=self.build_order_memory_seed(user_id=user_id, order=queued),
)
queued_count += 1
)
context["active_domain"] = first["domain"]
context["generic_memory"] = self.build_order_memory_seed(user_id=user_id, order=first)
self._save_context(user_id=user_id, context=context)
@ -292,9 +309,10 @@ class ConversationPolicy:
{
"domain": order["domain"],
"message": order["message"],
"seed_message": self.build_order_execution_message(order["domain"], order["message"]),
"memory_seed": self.build_order_memory_seed(user_id=user_id, order=order),
}
for order in orders[:2]
for order in orders
],
"expires_at": utc_now() + timedelta(minutes=PENDING_ORDER_SELECTION_TTL_MINUTES),
}
@ -305,15 +323,68 @@ class ConversationPolicy:
def render_order_selection_prompt(self, orders: list[dict]) -> str:
if len(orders) < 2:
return "Qual das acoes voce quer iniciar primeiro?"
first_label = self.describe_order_selection_option(orders[0])
second_label = self.describe_order_selection_option(orders[1])
enumerated_orders = "\n".join(
f"{index}. {self.describe_order_selection_option(order)}"
for index, order in enumerate(orders, start=1)
)
return (
"Identifiquei duas acoes na sua mensagem:\n"
f"1. {first_label}\n"
f"2. {second_label}\n"
f"Identifiquei {len(orders)} acoes na sua mensagem:\n"
f"{enumerated_orders}\n"
"Qual delas voce quer iniciar primeiro? Se for indiferente, eu escolho."
)
def build_order_execution_message(self, domain: str, order_message: str | None) -> str:
raw_message = str(order_message or "").strip()
normalized = self.service.normalizer.normalize_text(raw_message).strip()
if domain == "sales" and normalized in {"compra", "comprar", "venda", "pedido"}:
return "quero comprar um veiculo"
if domain == "review" and normalized in {"revisao", "agendamento", "agendar", "marcar revisao"}:
return "quero agendar revisao"
if domain == "rental" and normalized in {"aluguel", "alugar", "locacao", "locar"}:
return "quero alugar um carro"
return raw_message
def augment_actionable_orders_from_message(self, message: str, extracted_orders: list[dict]) -> list[dict]:
normalized = self.service.normalizer.normalize_text(message).strip()
if not normalized:
return extracted_orders
existing_domains = {
str(order.get("domain") or "general")
for order in extracted_orders
if isinstance(order, dict)
}
domain_hints = (
("sales", {"compra", "comprar", "venda", "pedido"}, "compra"),
("review", {"revisao", "agendamento", "agendar", "remarcar"}, "revisao"),
("rental", {"aluguel", "alugar", "locacao", "locar"}, "aluguel"),
)
augmented = list(extracted_orders)
for domain, terms, label in domain_hints:
if domain in existing_domains:
continue
if any(term in normalized for term in terms):
augmented.append(
{
"domain": domain,
"message": label,
"entities": self.service.normalizer.empty_extraction_payload(),
}
)
return augmented
def render_multi_order_clarification_prompt(self, orders: list[dict]) -> str:
if not orders:
return "Identifiquei mais de um assunto. Me diga qual voce quer iniciar primeiro."
options = "\n".join(
f"- {self.describe_order_selection_option(order)}"
for order in orders[:3]
)
return (
"Identifiquei mais de um assunto na sua mensagem:\n"
f"{options}\n"
"Para eu nao misturar os fluxos, me diga qual deles voce quer comecar primeiro."
)
# Formata o rótulo do pedido para exibição.
def describe_order_selection_option(self, order: dict) -> str:
@ -322,6 +393,7 @@ class ConversationPolicy:
domain_prefix = {
"review": "Revisao",
"sales": "Venda",
"rental": "Locacao",
"general": "Atendimento",
}.get(domain, "Atendimento")
return f"{domain_prefix}: {message}"
@ -396,7 +468,7 @@ class ConversationPolicy:
def looks_like_fresh_operational_request(self, message: str, turn_decision: dict | None = None) -> bool:
decision_domain = self._decision_domain(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 self.looks_like_fresh_operational_request_from_text(message)
@ -415,9 +487,147 @@ class ConversationPolicy:
"veiculo",
"remarcar",
"tambem",
"aluguel",
"alugar",
"locacao",
"locar",
"devolver",
}
return self.contains_any_term(normalized, operational_terms)
def is_explicit_pending_order_selection_message(
self,
message: str,
turn_decision: dict | None = None,
) -> bool:
if self._decision_selection_index(turn_decision) is not None:
return True
normalized = self.strip_choice_message(self.service.normalizer.normalize_text(message))
if not normalized:
return False
indifferent_tokens = {
"tanto faz",
"indiferente",
"qualquer um",
"qualquer uma",
"voce escolhe",
"pode escolher",
"fica a seu criterio",
}
if normalized in indifferent_tokens:
return True
if re.fullmatch(r"(?:opcao|acao|pedido)?\s*(\d+)", normalized):
return True
explicit_selection_messages = {
"compra",
"comprar",
"quero comprar",
"quero comprar um veiculo",
"venda",
"pedido",
"revisao",
"agendamento",
"agendar",
"agendar revisao",
"quero agendar revisao",
"aluguel",
"alugar",
"quero alugar",
"quero alugar um carro",
"locacao",
"locar",
}
return normalized in explicit_selection_messages
def derive_operational_task_key(
self,
*,
message: str,
turn_decision: dict | None = None,
fallback_domain: str | None = None,
) -> str | None:
normalized = self.service.normalizer.normalize_text(message).strip()
domain = self._decision_domain(turn_decision) or str(fallback_domain or "").strip().lower()
intent = self._decision_intent(turn_decision)
tool_name = str((turn_decision or {}).get("tool_name") or "").strip().lower()
if domain == "sales":
if intent == "order_list" or tool_name == "listar_pedidos" or "quais pedidos" in normalized:
return "sales:list"
if intent == "order_cancel" or tool_name == "cancelar_pedido" or ("cancel" in normalized and "pedido" in normalized):
return "sales:cancel"
if tool_name == "avaliar_veiculo_troca" or ("avali" in normalized and "troca" in normalized):
return "sales:trade_in"
if (
intent in {"order_create", "inventory_search"}
or tool_name in {"consultar_estoque", "realizar_pedido"}
or self.contains_any_term(normalized, {"compra", "comprar", "venda", "carro", "veiculo"})
):
return "sales:create"
if domain == "review":
if intent == "review_list" or tool_name == "listar_agendamentos_revisao" or "agendamentos" in normalized:
return "review:list"
if intent == "review_cancel" or ("cancel" in normalized and "revis" in normalized):
return "review:cancel"
if intent == "review_reschedule" or "remarc" in normalized:
return "review:reschedule"
if (
intent == "review_schedule"
or tool_name == "agendar_revisao"
or self.contains_any_term(normalized, {"revisao", "agendar", "agendamento"})
):
return "review:schedule"
if domain == "rental":
if intent == "rental_list" or tool_name == "consultar_frota_aluguel" or "frota" in normalized:
return "rental:list"
if tool_name == "registrar_devolucao_aluguel" or "devol" in normalized:
return "rental:return"
if tool_name == "registrar_pagamento_aluguel" or "comprovante" in normalized or "pagamento" in normalized:
return "rental:payment"
if tool_name == "registrar_multa_aluguel" or "multa" in normalized:
return "rental:fine"
if (
intent == "rental_create"
or self.contains_any_term(normalized, {"aluguel", "alugar", "locacao", "locar"})
):
return "rental:create"
return None
def derive_pending_order_task_key(self, order: dict) -> str | None:
return self.derive_operational_task_key(
message=str(order.get("seed_message") or order.get("message") or ""),
fallback_domain=str(order.get("domain") or "general"),
)
def queue_pending_orders_for_later(
self,
*,
user_id: int | None,
orders: list[dict],
skip_task_key: str | None = None,
) -> int:
queued_count = 0
skipped_matching_task = False
for order in orders:
if skip_task_key and not skipped_matching_task and self.derive_pending_order_task_key(order) == skip_task_key:
skipped_matching_task = True
continue
queued_count += int(
self.queue_order_with_memory_seed(
user_id=user_id,
domain=order["domain"],
order_message=order["message"],
memory_seed=order.get("memory_seed"),
)
)
return queued_count
# Distingue um comando global explicito de cancelamento do fluxo atual de um texto livre
# que deve ser consumido como dado do rascunho aberto.
@ -507,26 +717,36 @@ class ConversationPolicy:
}
if normalized in indifferent_tokens:
return 0, True
numeric_match = re.fullmatch(r"(?:opcao|acao|pedido)?\s*(\d+)", normalized)
if numeric_match:
candidate = int(numeric_match.group(1)) - 1
if 0 <= candidate < len(orders):
return candidate, False
if normalized in {"1", "primeiro", "primeira", "opcao 1", "acao 1", "pedido 1"}:
return 0, False
if normalized in {"2", "segundo", "segunda", "opcao 2", "acao 2", "pedido 2"}:
return 1, False
if normalized in {"3", "terceiro", "terceira", "opcao 3", "acao 3", "pedido 3"}:
return (2, False) if len(orders) >= 3 else (None, False)
decision_domain = self._decision_domain(turn_decision)
if len(orders) >= 2 and decision_domain in {"review", "sales"}:
if len(orders) >= 2 and decision_domain in ACTIONABLE_ORDER_DOMAINS:
matches = [index for index, order in enumerate(orders) if order.get("domain") == decision_domain]
if len(matches) == 1:
return matches[0], False
review_matches = [index for index, order in enumerate(orders) if order.get("domain") == "review"]
sales_matches = [index for index, order in enumerate(orders) if order.get("domain") == "sales"]
rental_matches = [index for index, order in enumerate(orders) if order.get("domain") == "rental"]
has_review_signal = self.contains_any_term(normalized, {"revisao", "agendamento", "agendar", "remarcar", "pos venda"})
has_sales_signal = self.contains_any_term(normalized, {"venda", "compra", "comprar", "pedido", "cancelamento", "cancelar", "carro", "veiculo"})
has_rental_signal = self.contains_any_term(normalized, {"aluguel", "locacao", "alugar", "locar", "devolucao", "frota"})
if len(review_matches) == 1 and has_review_signal and not has_sales_signal:
return review_matches[0], False
if len(sales_matches) == 1 and has_sales_signal and not has_review_signal:
return sales_matches[0], False
if len(rental_matches) == 1 and has_rental_signal and not has_review_signal and not has_sales_signal:
return rental_matches[0], False
return None, False
@ -561,26 +781,59 @@ class ConversationPolicy:
return "Tudo bem. Limpei o contexto atual. Pode me dizer o que voce quer fazer agora?"
return await self.service.handle_message(cleaned_message, user_id=user_id)
selected_index, auto_selected = self.detect_selected_order_index(
if (
self.looks_like_fresh_operational_request(message, turn_decision=turn_decision)
and not self.is_explicit_pending_order_selection_message(message, turn_decision=turn_decision)
):
current_task_key = self.derive_operational_task_key(
message=message,
orders=orders,
turn_decision=turn_decision,
)
if selected_index is None:
if self.looks_like_fresh_operational_request(message, turn_decision=turn_decision):
matching_indexes = [
index
for index, order in enumerate(orders)
if current_task_key and self.derive_pending_order_task_key(order) == current_task_key
]
if len(matching_indexes) == 1:
selected_index = matching_indexes[0]
selected_order = orders[selected_index]
context["pending_order_selection"] = None
self.queue_pending_orders_for_later(
user_id=user_id,
orders=[order for index, order in enumerate(orders) if index != selected_index],
)
intro = f"Perfeito. Vou comecar por: {self.describe_order_selection_option(selected_order)}"
selected_memory = dict(selected_order.get("memory_seed") or {})
context["active_domain"] = selected_order.get("domain") or context.get("active_domain", "general")
if selected_memory:
context["generic_memory"] = selected_memory
self._save_context(user_id=user_id, context=context)
next_response = await self.service.handle_message(message, user_id=user_id)
return f"{intro}\n{next_response}"
context["pending_order_selection"] = None
self._save_context(user_id=user_id, context=context)
self.queue_pending_orders_for_later(
user_id=user_id,
orders=orders,
skip_task_key=current_task_key,
)
return None
selected_index, auto_selected = self.detect_selected_order_index(
message=message,
orders=orders,
turn_decision=turn_decision,
)
if selected_index is None:
return self.render_order_selection_prompt(orders)
selected_order = orders[selected_index]
remaining_order = orders[1 - selected_index]
context["pending_order_selection"] = None
self.queue_order_with_memory_seed(
self.queue_pending_orders_for_later(
user_id=user_id,
domain=remaining_order["domain"],
order_message=remaining_order["message"],
memory_seed=remaining_order.get("memory_seed"),
orders=[order for index, order in enumerate(orders) if index != selected_index],
)
intro = (
@ -589,10 +842,12 @@ class ConversationPolicy:
else f"Perfeito. Vou comecar por: {self.describe_order_selection_option(selected_order)}"
)
selected_memory = dict(selected_order.get("memory_seed") or {})
context["active_domain"] = selected_order.get("domain") or context.get("active_domain", "general")
if selected_memory:
context["generic_memory"] = selected_memory
self._save_context(user_id=user_id, context=context)
next_response = await self.service.handle_message(str(selected_order.get("message") or ""), user_id=user_id)
selected_message = str(selected_order.get("seed_message") or selected_order.get("message") or "")
next_response = await self.service.handle_message(selected_message, user_id=user_id)
return f"{intro}\n{next_response}"
@ -710,7 +965,7 @@ class ConversationPolicy:
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"}:
return True
if self._decision_domain(turn_decision) in {"review", "sales"}:
if self._decision_domain(turn_decision) in {"review", "sales", "rental"}:
return True
return self.service._is_affirmative_message(message) or self.service._is_negative_message(message)
@ -801,6 +1056,11 @@ class ConversationPolicy:
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)
)
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
@ -814,6 +1074,8 @@ class ConversationPolicy:
self.service._reset_pending_review_states(user_id=user_id)
if previous_domain == "sales":
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["generic_memory"] = self.service._new_tab_memory(user_id=user_id)
context["pending_order_selection"] = None
@ -838,7 +1100,7 @@ class ConversationPolicy:
context["pending_switch"] = None
self._save_context(user_id=user_id, context=context)
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"]
):
context["pending_switch"] = None
@ -890,6 +1152,7 @@ class ConversationPolicy:
labels = {
"review": "agendamento de revisao",
"sales": "compra de veiculo",
"rental": "locacao de veiculo",
"general": "atendimento geral",
}
return labels.get(domain, "atendimento")
@ -909,6 +1172,8 @@ class ConversationPolicy:
return "Pode me dizer a faixa de preco, o modelo ou o tipo de carro que voce procura."
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."
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?"
def render_context_switched_message(self, target_domain: str) -> str:
@ -1008,9 +1273,15 @@ class ConversationPolicy:
selected_vehicle = context.get("selected_vehicle")
if isinstance(selected_vehicle, dict) and 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 []
if isinstance(stock_results, list) and stock_results:
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")
if isinstance(last_tool_result, dict) and last_tool_result.get("tool_name"):
tool_name = str(last_tool_result.get("tool_name") or "").strip()
@ -1125,4 +1396,22 @@ class ConversationPolicy:
payload = stock_selection.get("payload")
if isinstance(payload, list) and payload:
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)

@ -1,6 +1,7 @@
from datetime import datetime, timedelta
from app.core.time_utils import utc_now
from threading import RLock
from app.core.time_utils import utc_now
from app.services.orchestration.conversation_state_repository import ConversationStateRepository
@ -8,6 +9,7 @@ from app.services.orchestration.conversation_state_repository import Conversatio
# Serve como fallback simples para desenvolvimento e testes.
class ConversationStateStore(ConversationStateRepository):
def __init__(self) -> None:
self._lock = RLock()
self.user_contexts: dict[int, dict] = {}
self.pending_review_confirmations: dict[int, dict] = {}
self.pending_review_drafts: dict[int, dict] = {}
@ -17,10 +19,15 @@ class ConversationStateStore(ConversationStateRepository):
self.pending_order_drafts: dict[int, dict] = {}
self.pending_cancel_order_drafts: dict[int, dict] = {}
self.pending_stock_selections: dict[int, dict] = {}
self.pending_rental_drafts: dict[int, dict] = {}
self.pending_rental_selections: dict[int, dict] = {}
self.telegram_processed_messages: dict[int, dict] = {}
self.telegram_runtime_state: dict[int, dict] = {}
def upsert_user_context(self, user_id: int | None, ttl_minutes: int) -> None:
if user_id is None:
return
with self._lock:
now = utc_now()
context = self.user_contexts.get(user_id)
if context and context["expires_at"] >= now:
@ -39,12 +46,15 @@ class ConversationStateStore(ConversationStateRepository):
"pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
"last_rental_results": [],
"selected_rental_vehicle": None,
"expires_at": now + timedelta(minutes=ttl_minutes),
}
def get_user_context(self, user_id: int | None) -> dict | None:
if user_id is None:
return None
with self._lock:
context = self.user_contexts.get(user_id)
if not context:
return None
@ -56,11 +66,20 @@ class ConversationStateStore(ConversationStateRepository):
def save_user_context(self, user_id: int | None, context: dict) -> None:
if user_id is None or not isinstance(context, dict):
return
self.user_contexts[user_id] = context
with self._lock:
stored_context = dict(context)
if "expires_at" not in stored_context:
existing = self.user_contexts.get(user_id)
if isinstance(existing, dict) and existing.get("expires_at") is not None:
stored_context["expires_at"] = existing["expires_at"]
else:
stored_context["expires_at"] = utc_now() + timedelta(minutes=60)
self.user_contexts[user_id] = stored_context
def get_entry(self, bucket: str, user_id: int | None, *, expire: bool = False) -> dict | None:
if user_id is None:
return None
with self._lock:
entries = getattr(self, bucket)
entry = entries.get(user_id)
if not entry:
@ -73,9 +92,11 @@ class ConversationStateStore(ConversationStateRepository):
def set_entry(self, bucket: str, user_id: int | None, value: dict) -> None:
if user_id is None:
return
with self._lock:
getattr(self, bucket)[user_id] = value
def pop_entry(self, bucket: str, user_id: int | None) -> dict | None:
if user_id is None:
return None
with self._lock:
return getattr(self, bucket).pop(user_id, None)

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

@ -0,0 +1,420 @@
from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from app.services.orchestration.orchestrator_config import USER_CONTEXT_TTL_MINUTES
class OrchestratorContextManager:
"""Agrupa a gestao de contexto e efeitos colaterais do turno."""
def __init__(self, service) -> None:
self.service = service
def upsert_user_context(self, user_id: int | None) -> None:
override = self.service.__dict__.get("_upsert_user_context")
if callable(override):
override(user_id)
return
state = getattr(self.service, "state", None)
if state is None:
return
state.upsert_user_context(
user_id=user_id,
ttl_minutes=USER_CONTEXT_TTL_MINUTES,
)
def get_user_context(self, user_id: int | None) -> dict | None:
override = self.service.__dict__.get("_get_user_context")
if callable(override):
return override(user_id)
state = getattr(self.service, "state", None)
if state is None:
return None
return 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
override = self.service.__dict__.get("_save_user_context")
if callable(override):
override(user_id, context)
return
state = getattr(self.service, "state", None)
if state is None:
return
state.save_user_context(user_id=user_id, context=context)
def extract_generic_memory_fields(self, llm_generic_fields: dict | None = None) -> dict:
extracted: dict[str, Any] = {}
llm_fields = llm_generic_fields or {}
normalized_plate = self.service._normalize_plate(llm_fields.get("placa"))
if normalized_plate:
extracted["placa"] = normalized_plate
normalized_cpf = self.service._normalize_cpf(llm_fields.get("cpf"))
if normalized_cpf:
extracted["cpf"] = normalized_cpf
normalized_budget = self.service._normalize_positive_number(llm_fields.get("orcamento_max"))
if normalized_budget:
extracted["orcamento_max"] = int(round(normalized_budget))
normalized_profile = self.service._normalize_vehicle_profile(llm_fields.get("perfil_veiculo"))
if normalized_profile:
extracted["perfil_veiculo"] = normalized_profile
return extracted
def capture_generic_memory(
self,
user_id: int | None,
llm_generic_fields: dict | None = None,
) -> None:
context = self.get_user_context(user_id)
if not context:
return
fields = self.extract_generic_memory_fields(llm_generic_fields=llm_generic_fields)
if fields:
context["generic_memory"].update(fields)
context.setdefault("shared_memory", {}).update(fields)
self.save_user_context(user_id=user_id, context=context)
def capture_tool_result_context(
self,
tool_name: str,
tool_result,
user_id: int | None,
) -> None:
context = self.get_user_context(user_id)
if not context:
return
context["last_tool_result"] = {
"tool_name": tool_name,
"result_type": type(tool_result).__name__,
}
if tool_name == "consultar_frota_aluguel" and isinstance(tool_result, list):
sanitized_rental = self.service._sanitize_rental_results(tool_result[:20])
context["last_rental_results"] = sanitized_rental
self.service._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):
self.save_user_context(user_id=user_id, context=context)
return
sanitized = self.service._sanitize_stock_results(tool_result[:5])
context["last_stock_results"] = sanitized
self.service._store_pending_stock_selection(
user_id=user_id,
stock_results=sanitized,
)
if sanitized:
context["selected_vehicle"] = None
self.save_user_context(user_id=user_id, context=context)
def capture_successful_tool_side_effects(
self,
tool_name: str,
arguments: dict | None,
tool_result,
user_id: int | None,
) -> None:
if tool_name == "agendar_revisao" and isinstance(arguments, dict):
self.service._store_last_review_package(user_id=user_id, payload=arguments)
if tool_name in {
"abrir_locacao_aluguel",
"registrar_devolucao_aluguel",
"registrar_pagamento_aluguel",
"registrar_multa_aluguel",
} and isinstance(tool_result, dict):
self.service._store_last_rental_contract(user_id=user_id, payload=tool_result)
self.capture_tool_result_context(
tool_name=tool_name,
tool_result=tool_result,
user_id=user_id,
)
async def maybe_build_stock_suggestion_response(
self,
tool_name: str,
arguments: dict | None,
tool_result,
user_id: int | None,
) -> str | None:
if tool_name != "consultar_estoque" or not isinstance(tool_result, list) or tool_result:
return None
budget = self.service._normalize_positive_number((arguments or {}).get("preco_max"))
if not budget:
return None
relaxed_arguments = dict(arguments or {})
relaxed_arguments["preco_max"] = max(float(budget) * 1.2, float(budget) + 10000.0)
relaxed_arguments["limite"] = min(max(int((arguments or {}).get("limite") or 5), 1), 5)
relaxed_arguments["ordenar_preco"] = "asc"
try:
relaxed_result = await self.service.tool_executor.execute(
"consultar_estoque",
relaxed_arguments,
user_id=user_id,
)
except HTTPException:
return None
if not isinstance(relaxed_result, list):
return None
nearby = []
for item in relaxed_result:
if not isinstance(item, dict):
continue
try:
price = float(item.get("preco") or 0)
except (TypeError, ValueError):
continue
if price > float(budget):
nearby.append(item)
if not nearby:
return None
nearby = [{**item, "budget_relaxed": True} for item in nearby]
self.capture_tool_result_context(
tool_name="consultar_estoque",
tool_result=nearby,
user_id=user_id,
)
budget_label = f"R$ {float(budget):,.0f}".replace(",", ".")
lines = [f"Nao encontrei veiculos ate {budget_label}."]
lines.append("Mas achei algumas opcoes proximas ao seu orcamento:")
for idx, item in enumerate(nearby[:5], start=1):
modelo = str(item.get("modelo") or "N/A")
categoria = str(item.get("categoria") or "N/A")
codigo = item.get("id", "N/A")
preco = f"R$ {float(item.get('preco') or 0):,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
lines.append(f"{idx}. [{codigo}] {modelo} ({categoria}) - {preco}")
lines.append("Se quiser, responda com o numero da lista ou com o modelo.")
return "\n".join(lines)
def new_tab_memory(self, user_id: int | None) -> dict:
context = self.get_user_context(user_id)
if not context:
return {}
shared = context.get("shared_memory", {})
if not isinstance(shared, dict):
return {}
return dict(shared)
def reset_pending_rental_states(self, user_id: int | None) -> None:
if user_id is None:
return
self.service.state.pop_entry("pending_rental_drafts", user_id)
self.service.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:
if user_id is None:
return
self.service.state.pop_entry("pending_review_drafts", user_id)
self.service.state.pop_entry("pending_review_confirmations", user_id)
self.service.state.pop_entry("pending_review_management_drafts", user_id)
self.service.state.pop_entry("pending_review_reuse_confirmations", user_id)
context = self.get_user_context(user_id)
if isinstance(context, dict):
snapshots = context.get("flow_snapshots")
if isinstance(snapshots, dict):
snapshots.pop("review_schedule", None)
snapshots.pop("review_confirmation", None)
snapshots.pop("review_management", None)
snapshots.pop("review_reuse_confirmation", None)
collected_slots = context.get("collected_slots")
if isinstance(collected_slots, dict):
collected_slots.pop("review_schedule", None)
collected_slots.pop("review_management", None)
if context.get("active_task") in {"review_schedule", "review_management"}:
context["active_task"] = None
self.save_user_context(user_id=user_id, context=context)
def reset_pending_order_states(self, user_id: int | None) -> None:
if user_id is None:
return
self.service.state.pop_entry("pending_order_drafts", user_id)
self.service.state.pop_entry("pending_cancel_order_drafts", user_id)
self.service.state.pop_entry("pending_stock_selections", user_id)
context = self.get_user_context(user_id)
if isinstance(context, dict):
snapshots = context.get("flow_snapshots")
if isinstance(snapshots, dict):
snapshots.pop("order_create", None)
snapshots.pop("order_cancel", None)
collected_slots = context.get("collected_slots")
if isinstance(collected_slots, dict):
collected_slots.pop("order_create", None)
collected_slots.pop("order_cancel", None)
if context.get("active_task") in {"order_create", "order_cancel"}:
context["active_task"] = None
self.save_user_context(user_id=user_id, context=context)
def clear_user_conversation_state(self, user_id: int | None) -> None:
context = self.get_user_context(user_id)
if not context:
return
self.reset_pending_review_states(user_id=user_id)
self.reset_pending_order_states(user_id=user_id)
self.reset_pending_rental_states(user_id=user_id)
self.service.state.pop_entry("last_review_packages", user_id)
context["active_domain"] = "general"
context["active_task"] = None
context["generic_memory"] = {}
context["shared_memory"] = {}
context["collected_slots"] = {}
context["flow_snapshots"] = {}
context["last_tool_result"] = None
context["order_queue"] = []
context["pending_order_selection"] = None
context["pending_switch"] = None
context["last_stock_results"] = []
context["selected_vehicle"] = None
context["last_rental_results"] = []
context["selected_rental_vehicle"] = None
context.pop("last_rental_contract", None)
self.save_user_context(user_id=user_id, context=context)
def clear_pending_order_navigation(self, user_id: int | None) -> int:
context = self.get_user_context(user_id)
if not context:
return 0
dropped = len(context.get("order_queue", []))
if context.get("pending_switch"):
dropped += 1
if context.get("pending_order_selection"):
pending_orders = context["pending_order_selection"].get("orders") or []
dropped += len(pending_orders)
context["order_queue"] = []
context["pending_switch"] = None
context["pending_order_selection"] = None
self.save_user_context(user_id=user_id, context=context)
return dropped
def cancel_active_flow(self, user_id: int | None) -> str:
context = self.get_user_context(user_id)
if not context:
return "Nao havia contexto ativo para cancelar."
active_domain = context.get("active_domain", "general")
had_flow = self.service._has_open_flow(user_id=user_id, domain=active_domain)
if active_domain == "review":
self.reset_pending_review_states(user_id=user_id)
elif active_domain == "sales":
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
self.save_user_context(user_id=user_id, context=context)
if had_flow:
return f"Fluxo atual de {self.service._domain_label(active_domain)} cancelado."
return "Nao havia fluxo em andamento para cancelar."
async def continue_next_order_now(self, user_id: int | None) -> str:
context = self.get_user_context(user_id)
if not context:
return "Nao encontrei contexto ativo para continuar."
if context.get("pending_order_selection"):
return "Ainda preciso que voce escolha qual das duas acoes deseja iniciar primeiro."
pending_switch = context.get("pending_switch")
if isinstance(pending_switch, dict):
queued_message = str(pending_switch.get("queued_message") or "").strip()
if queued_message:
target_domain = str(pending_switch.get("target_domain") or "general")
memory_seed = dict(pending_switch.get("memory_seed") or {})
self.service._apply_domain_switch(user_id=user_id, target_domain=target_domain)
refreshed = self.get_user_context(user_id)
if refreshed is not None:
refreshed["generic_memory"] = memory_seed
self.save_user_context(user_id=user_id, context=refreshed)
transition = self.service._build_next_order_transition(target_domain)
next_response = await self.service.handle_message(queued_message, user_id=user_id)
return f"{transition}\n{next_response}"
next_order = self.service._pop_next_order(user_id=user_id)
if not next_order:
return "Nao ha pedidos pendentes na fila para continuar."
target_domain = str(next_order.get("domain") or "general")
memory_seed = dict(next_order.get("memory_seed") or self.new_tab_memory(user_id=user_id))
self.service._apply_domain_switch(user_id=user_id, target_domain=target_domain)
refreshed = self.get_user_context(user_id)
if refreshed is not None:
refreshed["generic_memory"] = memory_seed
self.save_user_context(user_id=user_id, context=refreshed)
transition = self.service._build_next_order_transition(target_domain)
next_response = await self.service.handle_message(
str(next_order.get("message") or ""),
user_id=user_id,
)
return f"{transition}\n{next_response}"
async def tool_limpar_contexto_conversa(
self,
motivo: str | None = None,
user_id: int | None = None,
) -> dict:
self.clear_user_conversation_state(user_id=user_id)
message = "Contexto da conversa limpo. Podemos recomecar do zero."
if motivo:
message = f"{message}\nMotivo registrado: {motivo.strip()}"
return {"message": message}
async def tool_descartar_pedidos_pendentes(
self,
motivo: str | None = None,
user_id: int | None = None,
) -> dict:
dropped = self.clear_pending_order_navigation(user_id=user_id)
if dropped <= 0:
message = "Nao havia pedidos pendentes na fila para descartar."
elif dropped == 1:
message = "Descartei 1 pedido pendente da fila."
else:
message = f"Descartei {dropped} pedidos pendentes da fila."
if motivo:
message = f"{message}\nMotivo registrado: {motivo.strip()}"
return {"message": message}
async def tool_cancelar_fluxo_atual(
self,
motivo: str | None = None,
user_id: int | None = None,
) -> dict:
message = self.cancel_active_flow(user_id=user_id)
if motivo:
message = f"{message}\nMotivo registrado: {motivo.strip()}"
return {"message": message}
async def tool_continuar_proximo_pedido(self, user_id: int | None = None) -> str:
return await self.continue_next_order_now(user_id=user_id)

@ -0,0 +1,296 @@
from __future__ import annotations
import json
import logging
from time import perf_counter
from typing import Any
from fastapi import HTTPException
from app.core.time_utils import utc_now
from app.services.orchestration.entity_normalizer import EntityNormalizer
from app.services.orchestration.orchestrator_config import (
DETERMINISTIC_RESPONSE_TOOLS,
LOW_VALUE_RESPONSES,
ORCHESTRATION_CONTROL_TOOLS,
)
from app.services.orchestration.prompt_builders import (
build_force_tool_prompt,
build_result_prompt,
build_router_prompt,
)
from app.services.orchestration.sensitive_data import mask_sensitive_payload, mask_sensitive_text
logger = logging.getLogger(__name__)
class OrchestratorExecutionManager:
"""Centraliza instrumentacao, prompts e execucao tecnica de tools."""
def __init__(self, service, logger_instance=None) -> None:
self.service = service
self.logger = logger_instance or logger
def build_router_prompt(self, user_message: str, user_id: int | None) -> str:
conversation_context = self.service._build_context_summary(user_id=user_id)
return build_router_prompt(
user_message=user_message,
user_id=user_id,
conversation_context=conversation_context,
)
def build_force_tool_prompt(self, user_message: str, user_id: int | None) -> str:
conversation_context = self.service._build_context_summary(user_id=user_id)
return build_force_tool_prompt(
user_message=user_message,
user_id=user_id,
conversation_context=conversation_context,
)
def build_result_prompt(
self,
user_message: str,
user_id: int | None,
tool_name: str,
tool_result,
) -> str:
conversation_context = self.service._build_context_summary(user_id=user_id)
return build_result_prompt(
user_message=user_message,
user_id=user_id,
tool_name=tool_name,
tool_result=tool_result,
conversation_context=conversation_context,
)
def capture_turn_decision_trace(self, turn_decision: dict | None) -> None:
trace = getattr(self.service, "_turn_trace", None)
if not isinstance(trace, dict) or not isinstance(turn_decision, dict):
return
trace["intent"] = str(turn_decision.get("intent") or "").strip() or None
trace["domain"] = str(turn_decision.get("domain") or "").strip() or None
trace["action"] = str(turn_decision.get("action") or "").strip() or None
def capture_tool_invocation_trace(self, tool_name: str, arguments: dict | None) -> None:
trace = getattr(self.service, "_turn_trace", None)
if not isinstance(trace, dict):
return
trace["tool_name"] = str(tool_name or "").strip() or None
trace["tool_arguments"] = mask_sensitive_payload(dict(arguments or {})) if isinstance(arguments, dict) else None
def finalize_turn_history(
self,
*,
user_message: str,
assistant_response: str | None,
turn_status: str,
error_detail: str | None = None,
) -> None:
history_service = getattr(self.service, "history_service", None)
if history_service is None:
return
trace = getattr(self.service, "_turn_trace", {}) or {}
history_service.record_turn(
request_id=str(trace.get("request_id") or ""),
conversation_id=str(trace.get("conversation_id") or "anonymous"),
user_id=trace.get("user_id"),
user_message=str(user_message or ""),
assistant_response=assistant_response,
turn_status=str(turn_status or "completed"),
intent=trace.get("intent"),
domain=trace.get("domain"),
action=trace.get("action"),
tool_name=trace.get("tool_name"),
tool_arguments=trace.get("tool_arguments"),
error_detail=error_detail,
started_at=trace.get("started_at"),
completed_at=utc_now(),
elapsed_ms=trace.get("elapsed_ms"),
)
def format_turn_error(self, exc: Exception) -> str:
if isinstance(exc, HTTPException):
detail = exc.detail
if isinstance(detail, dict):
return json.dumps(mask_sensitive_payload(detail), ensure_ascii=True, separators=(",", ":"), default=str)
return str(mask_sensitive_text(str(detail)))
return str(mask_sensitive_text(f"{type(exc).__name__}: {exc}"))
def log_turn_event(self, event: str, **payload) -> None:
trace = getattr(self.service, "_turn_trace", {}) or {}
safe_payload = mask_sensitive_payload(
{
"request_id": trace.get("request_id"),
"conversation_id": trace.get("conversation_id"),
**payload,
}
)
self.logger.info(
"turn_event=%s payload=%s",
event,
safe_payload,
)
async def call_llm_with_trace(self, operation: str, message: str, tools):
started_at = perf_counter()
try:
result = await self.service.llm.generate_response(message=message, tools=tools)
elapsed_ms = round((perf_counter() - started_at) * 1000, 2)
self.log_turn_event(
"llm_completed",
operation=operation,
elapsed_ms=elapsed_ms,
tool_call=bool(result.get("tool_call")),
)
return result
except Exception:
elapsed_ms = round((perf_counter() - started_at) * 1000, 2)
self.log_turn_event(
"llm_failed",
operation=operation,
elapsed_ms=elapsed_ms,
)
raise
def merge_pending_draft_tool_arguments(
self,
tool_name: str,
arguments: dict,
user_id: int | None,
) -> dict:
if user_id is None or not isinstance(arguments, dict):
return dict(arguments or {})
if not hasattr(self.service, "state") or self.service.state is None:
return dict(arguments)
bucket_map = {
"agendar_revisao": "pending_review_drafts",
"realizar_pedido": "pending_order_drafts",
"cancelar_pedido": "pending_cancel_order_drafts",
"cancelar_agendamento_revisao": "pending_review_management_drafts",
"editar_data_revisao": "pending_review_management_drafts",
}
bucket = bucket_map.get(tool_name)
if not bucket:
return dict(arguments)
draft = self.service.state.get_entry(bucket, user_id, expire=True)
if not isinstance(draft, dict):
return dict(arguments)
payload = draft.get("payload")
if not isinstance(payload, dict):
return dict(arguments)
merged_arguments = dict(payload)
merged_arguments.update(arguments)
return merged_arguments
def normalize_tool_invocation(
self,
tool_name: str,
arguments: dict | None,
user_id: int | None,
) -> tuple[str, dict]:
normalizer = getattr(self.service, "normalizer", None)
if normalizer is None:
normalizer = EntityNormalizer()
self.service.normalizer = normalizer
normalized_tool_name = normalizer.normalize_tool_name(tool_name) or str(tool_name or "").strip()
normalized_arguments = normalizer.normalize_tool_arguments(normalized_tool_name, arguments or {})
normalized_arguments = self.merge_pending_draft_tool_arguments(
tool_name=normalized_tool_name,
arguments=normalized_arguments,
user_id=user_id,
)
return normalized_tool_name, normalized_arguments
async def execute_tool_with_trace(self, tool_name: str, arguments: dict, user_id: int | None):
tool_name, arguments = self.normalize_tool_invocation(
tool_name=tool_name,
arguments=arguments,
user_id=user_id,
)
self.capture_tool_invocation_trace(tool_name=tool_name, arguments=arguments)
started_at = perf_counter()
try:
result = await self.service.tool_executor.execute(tool_name, arguments, user_id=user_id)
elapsed_ms = round((perf_counter() - started_at) * 1000, 2)
self.log_turn_event(
"tool_completed",
tool_name=tool_name,
elapsed_ms=elapsed_ms,
arguments=arguments,
result=result,
)
return result
except HTTPException as exc:
elapsed_ms = round((perf_counter() - started_at) * 1000, 2)
self.log_turn_event(
"tool_failed",
tool_name=tool_name,
elapsed_ms=elapsed_ms,
arguments=arguments,
error=self.service.tool_executor.coerce_http_error(exc),
)
raise
async def render_tool_response_with_fallback(
self,
user_message: str,
user_id: int | None,
tool_name: str,
tool_result,
) -> str:
fallback_response = self.fallback_format_tool_result(tool_name, tool_result)
if self.should_use_deterministic_response(tool_name):
self.log_turn_event(
"tool_response_fallback",
tool_name=tool_name,
reason="deterministic_tool",
)
return fallback_response
try:
final_response = await self.call_llm_with_trace(
operation="tool_result_response",
message=self.build_result_prompt(
user_message=user_message,
user_id=user_id,
tool_name=tool_name,
tool_result=tool_result,
),
tools=[],
)
except Exception:
self.log_turn_event(
"tool_response_fallback",
tool_name=tool_name,
reason="llm_failure",
)
return fallback_response
text = (final_response.get("response") or "").strip()
if self.is_low_value_response(text):
self.log_turn_event(
"tool_response_fallback",
tool_name=tool_name,
reason="low_value_response",
)
return fallback_response
return text or fallback_response
def should_use_deterministic_response(self, tool_name: str) -> bool:
return tool_name in DETERMINISTIC_RESPONSE_TOOLS or tool_name in ORCHESTRATION_CONTROL_TOOLS
def is_low_value_response(self, text: str) -> bool:
return text.strip().lower() in LOW_VALUE_RESPONSES
def http_exception_detail(self, exc: HTTPException) -> str:
return self.service.tool_executor.http_exception_detail(exc)
def fallback_format_tool_result(self, tool_name: str, tool_result: Any) -> str:
return self.service.tool_executor.fallback_format_tool_result(
tool_name=tool_name,
tool_result=tool_result,
)

File diff suppressed because it is too large Load Diff

@ -15,7 +15,8 @@ def build_router_prompt(
return (
"Voce e um assistente de concessionaria. "
"Sempre que a solicitacao depender de dados operacionais (estoque, validacao de cliente, "
"avaliacao de troca, agendamento de revisao, realizacao ou cancelamento de pedido), use a tool correta. "
"avaliacao de troca, agendamento de revisao, realizacao ou cancelamento de pedido, consulta de frota de aluguel, "
"abertura de locacao, devolucao de aluguel, registro de pagamento de aluguel ou registro de multa de aluguel), use a tool correta. "
"Se o usuario pedir para recomecar, esquecer contexto, cancelar fluxo atual, descartar fila pendente "
"ou continuar o proximo pedido, use a tool de orquestracao apropriada. "
"Mensagens de controle da conversa tem prioridade sobre qualquer fluxo em aberto. "
@ -36,6 +37,7 @@ def build_force_tool_prompt(
user_context = _build_user_context_line(user_id)
return (
"Reavalie a mensagem e priorize chamar tool se houver intencao operacional. "
"Considere tambem as operacoes de aluguel (consultar frota, abrir locacao, registrar devolucao, pagamento ou multa). "
"Considere tambem tools de orquestracao para limpar contexto, cancelar fluxo, descartar fila ou continuar o proximo pedido. "
"Mesmo com fluxo incremental ativo, se a mensagem for de controle global da conversa, a tool de orquestracao deve vencer o rascunho atual. "
"Use texto apenas quando faltar dado obrigatorio.\n\n"

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

@ -164,6 +164,92 @@ def fallback_format_tool_result(tool_name: str, tool_result: Any) -> str:
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}\n"
"Pagamento: em aberto\n"
"Quando quiser testar o comprovante, envie a imagem com os dados do pagamento."
)
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 {
"limpar_contexto_conversa",
"continuar_proximo_pedido",

@ -0,0 +1,144 @@
import re
from typing import Any
_CPF_PATTERN = re.compile(r"(?<!\d)(\d{3}\.?\d{3}\.?\d{3}-?\d{2})(?!\d)")
_PLATE_PATTERN = re.compile(r"(?<![A-Za-z0-9])([A-Za-z]{3}\d{4}|[A-Za-z]{3}\d[A-Za-z]\d{2})(?![A-Za-z0-9])")
_LABELED_EXTERNAL_ID_PATTERN = re.compile(
r'(?i)(["\']?external_id["\']?\s*[:=]\s*["\']?)([A-Za-z0-9._:-]{4,})'
)
_LABELED_RECEIPT_IDENTIFIER_PATTERN = re.compile(
r'(?i)(["\']?(?:identificador(?:_?do)?_?comprovante|comprovante_id|receipt_id|receipt_identifier|nsu|transaction_id|pix_e2e_id|end_to_end_id)["\']?\s*[:=]\s*["\']?)([A-Za-z0-9._:-]{4,})'
)
_CPF_KEYS = {
"cpf",
"customer_cpf",
"cpf_cliente",
}
_PLATE_KEYS = {
"placa",
"placa_veiculo",
"vehicle_plate",
"plate",
}
_EXTERNAL_ID_KEYS = {
"external_id",
}
_RECEIPT_IDENTIFIER_KEYS = {
"identificador_comprovante",
"comprovante_id",
"receipt_id",
"receipt_identifier",
"nsu",
"transaction_id",
"pix_e2e_id",
"end_to_end_id",
}
def mask_sensitive_text(value: str | None) -> str | None:
if value is None:
return None
text = str(value)
if not text:
return text
masked = _LABELED_EXTERNAL_ID_PATTERN.sub(
lambda match: f"{match.group(1)}{_mask_identifier_value(match.group(2), suffix=3)}",
text,
)
masked = _LABELED_RECEIPT_IDENTIFIER_PATTERN.sub(
lambda match: f"{match.group(1)}{_mask_identifier_value(match.group(2), suffix=3)}",
masked,
)
masked = _CPF_PATTERN.sub(lambda match: _mask_cpf_value(match.group(1)), masked)
masked = _PLATE_PATTERN.sub(lambda match: _mask_plate_value(match.group(1)), masked)
return masked
def mask_sensitive_payload(value: Any, *, key: str | None = None) -> Any:
key_kind = _classify_sensitive_key(key)
if key_kind is not None:
return _mask_value_by_kind(value, key_kind)
if isinstance(value, dict):
return {item_key: mask_sensitive_payload(item_value, key=item_key) for item_key, item_value in value.items()}
if isinstance(value, list):
return [mask_sensitive_payload(item, key=key) for item in value]
if isinstance(value, tuple):
return tuple(mask_sensitive_payload(item, key=key) for item in value)
if isinstance(value, set):
return {mask_sensitive_payload(item, key=key) for item in value}
if isinstance(value, str):
return mask_sensitive_text(value)
return value
def _classify_sensitive_key(key: str | None) -> str | None:
normalized = _normalize_key(key)
if not normalized:
return None
if normalized in _CPF_KEYS or normalized.endswith("_cpf"):
return "cpf"
if normalized in _PLATE_KEYS or normalized.endswith("_placa") or normalized.endswith("_plate"):
return "placa"
if normalized in _EXTERNAL_ID_KEYS:
return "external_id"
if normalized in _RECEIPT_IDENTIFIER_KEYS:
return "receipt_identifier"
return None
def _normalize_key(key: str | None) -> str:
return re.sub(r"[^a-z0-9]+", "_", str(key or "").strip().lower()).strip("_")
def _mask_value_by_kind(value: Any, kind: str) -> str | None:
if value is None:
return None
text = str(value).strip()
if not text:
return text
if "*" in text:
return text
if kind == "cpf":
return _mask_cpf_value(text)
if kind == "placa":
return _mask_plate_value(text)
if kind in {"external_id", "receipt_identifier"}:
return _mask_identifier_value(text, suffix=3)
return mask_sensitive_text(text)
def _mask_cpf_value(value: str) -> str:
if "*" in value:
return value
digits = re.sub(r"\D", "", str(value or ""))
if len(digits) >= 2:
return f"***.***.***-{digits[-2:]}"
return "***.***.***-**"
def _mask_plate_value(value: str) -> str:
if "*" in value:
return value
normalized = re.sub(r"[^A-Za-z0-9]", "", str(value or "")).upper()
if not normalized:
return "***"
if len(normalized) <= 4:
return "***"
hidden_count = max(len(normalized) - 4, 3)
return f"{normalized[:3]}{'*' * hidden_count}{normalized[-1:]}"
def _mask_identifier_value(value: str, *, suffix: int = 3) -> str:
if "*" in value:
return value
text = str(value or "").strip()
if not text:
return text
if len(text) <= suffix:
return "*" * max(len(text), 3)
hidden_count = max(len(text) - suffix, 3)
return f"{'*' * hidden_count}{text[-suffix:]}"

@ -3,6 +3,13 @@ from typing import Any
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.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 (
agendar_revisao,
cancelar_agendamento_revisao,
@ -26,9 +33,14 @@ __all__ = [
"cancelar_agendamento_revisao",
"cancelar_pedido",
"consultar_estoque",
"consultar_frota_aluguel",
"editar_data_revisao",
"listar_agendamentos_revisao",
"listar_pedidos",
"abrir_locacao_aluguel",
"registrar_devolucao_aluguel",
"registrar_multa_aluguel",
"registrar_pagamento_aluguel",
"realizar_pedido",
"validar_cliente_venda",
]

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

@ -0,0 +1,15 @@
[Unit]
Description=AI Orquestrador Database Bootstrap
After=network.target
[Service]
Type=oneshot
User=vitor
Group=vitor
WorkingDirectory=/opt/orquestrador
EnvironmentFile=/opt/orquestrador/.env.prod
Environment=PATH=/opt/orquestrador/venv/bin
ExecStart=/opt/orquestrador/venv/bin/python -m app.db.bootstrap
[Install]
WantedBy=multi-user.target

@ -9,7 +9,7 @@ Group=vitor
WorkingDirectory=/opt/orquestrador
EnvironmentFile=/opt/orquestrador/.env.prod
Environment=PATH=/opt/orquestrador/venv/bin
ExecStart=/bin/sh -c '/opt/orquestrador/venv/bin/python -m app.db.init_db && /opt/orquestrador/venv/bin/python -m app.integrations.telegram_satellite_service'
ExecStart=/opt/orquestrador/venv/bin/python -m app.integrations.telegram_satellite_service
Restart=always
RestartSec=5

@ -1,3 +1,31 @@
x-orquestrador-env: &orquestrador-env
GOOGLE_PROJECT_ID: ${GOOGLE_PROJECT_ID:-local-dev}
GOOGLE_LOCATION: ${GOOGLE_LOCATION:-us-central1}
VERTEX_MODEL_NAME: ${VERTEX_MODEL_NAME:-gemini-2.5-pro}
ENVIRONMENT: ${ENVIRONMENT:-development}
DEBUG: ${ORQUESTRADOR_DEBUG:-false}
DB_HOST: mysql
DB_PORT: 3306
DB_USER: root
DB_PASSWORD: root
DB_NAME: orquestrador_mock
MOCK_DB_HOST: mysql
MOCK_DB_PORT: 3306
MOCK_DB_USER: root
MOCK_DB_PASSWORD: root
MOCK_DB_NAME: orquestrador_mock
AUTO_SEED_TOOLS: ${AUTO_SEED_TOOLS:-true}
AUTO_SEED_MOCK: ${AUTO_SEED_MOCK:-true}
MOCK_SEED_ENABLED: ${MOCK_SEED_ENABLED:-true}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_POLLING_TIMEOUT: ${TELEGRAM_POLLING_TIMEOUT:-30}
TELEGRAM_REQUEST_TIMEOUT: ${TELEGRAM_REQUEST_TIMEOUT:-45}
CONVERSATION_STATE_BACKEND: ${CONVERSATION_STATE_BACKEND:-redis}
CONVERSATION_STATE_TTL_MINUTES: ${CONVERSATION_STATE_TTL_MINUTES:-60}
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
REDIS_KEY_PREFIX: ${REDIS_KEY_PREFIX:-orquestrador}
REDIS_SOCKET_TIMEOUT_SECONDS: ${REDIS_SOCKET_TIMEOUT_SECONDS:-5}
services:
mysql:
image: mysql:8.4
@ -30,33 +58,7 @@ services:
telegram:
build: .
container_name: orquestrador_telegram
environment:
GOOGLE_PROJECT_ID: ${GOOGLE_PROJECT_ID:-local-dev}
GOOGLE_LOCATION: ${GOOGLE_LOCATION:-us-central1}
VERTEX_MODEL_NAME: ${VERTEX_MODEL_NAME:-gemini-2.5-pro}
ENVIRONMENT: ${ENVIRONMENT:-development}
DEBUG: ${ORQUESTRADOR_DEBUG:-false}
DB_HOST: mysql
DB_PORT: 3306
DB_USER: root
DB_PASSWORD: root
DB_NAME: orquestrador_mock
MOCK_DB_HOST: mysql
MOCK_DB_PORT: 3306
MOCK_DB_USER: root
MOCK_DB_PASSWORD: root
MOCK_DB_NAME: orquestrador_mock
AUTO_SEED_TOOLS: ${AUTO_SEED_TOOLS:-true}
AUTO_SEED_MOCK: ${AUTO_SEED_MOCK:-true}
MOCK_SEED_ENABLED: ${MOCK_SEED_ENABLED:-true}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_POLLING_TIMEOUT: ${TELEGRAM_POLLING_TIMEOUT:-30}
TELEGRAM_REQUEST_TIMEOUT: ${TELEGRAM_REQUEST_TIMEOUT:-45}
CONVERSATION_STATE_BACKEND: ${CONVERSATION_STATE_BACKEND:-redis}
CONVERSATION_STATE_TTL_MINUTES: ${CONVERSATION_STATE_TTL_MINUTES:-60}
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
REDIS_KEY_PREFIX: ${REDIS_KEY_PREFIX:-orquestrador}
REDIS_SOCKET_TIMEOUT_SECONDS: ${REDIS_SOCKET_TIMEOUT_SECONDS:-5}
environment: *orquestrador-env
depends_on:
mysql:
condition: service_healthy
@ -64,5 +66,15 @@ services:
condition: service_healthy
restart: unless-stopped
bootstrap:
build: .
profiles: ["bootstrap"]
environment: *orquestrador-env
command: ["python", "-m", "app.db.bootstrap"]
depends_on:
mysql:
condition: service_healthy
restart: "no"
volumes:
mysql_data:

@ -162,5 +162,5 @@ if __name__ == "__main__":
parser.add_argument("--order-cycles", type=int, default=10)
parser.add_argument("--race-attempts", type=int, default=5)
parser.add_argument("--user-base", type=int, default=990000)
parser.add_argument("--cpf", default="10000000001")
parser.add_argument("--cpf", default="11144477735")
asyncio.run(main(parser.parse_args()))

@ -200,6 +200,58 @@ class ContextSummaryTests(unittest.TestCase):
self.assertIn("Dados atuais: placa=ABC1C23, modelo=Onix, ano=2024.", 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__":
unittest.main()

@ -9,6 +9,7 @@ os.environ.setdefault("DEBUG", "false")
from fastapi import HTTPException
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.integrations.telegram_satellite_service import _ensure_supported_runtime_configuration, _split_telegram_text
from app.models.tool_model import ToolDefinition
@ -84,6 +85,25 @@ class FakeRegistry:
]
reverse = str(arguments.get("ordenar_preco") or "asc").lower() == "desc"
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":
return [
{
@ -240,6 +260,66 @@ class OrderFlowHarness(OrderFlowMixin):
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):
def __init__(self, state, registry, review_now_provider=None):
self.state = state
@ -360,6 +440,24 @@ class ConversationAdjustmentsTests(unittest.TestCase):
self.assertIn("2. REV-2 | XYZ9999 |", response)
self.assertNotIn("\n\n", response)
def test_rental_open_formatter_marks_payment_as_pending(self):
response = fallback_format_tool_result(
"abrir_locacao_aluguel",
{
"contrato_numero": "LOC-20260318-FE69BCF0",
"placa": "RAA1A12",
"modelo_veiculo": "Peugeot 208",
"data_inicio": "2026-03-20T10:00:00",
"data_fim_prevista": "2026-03-23T10:00:00",
"valor_diaria": 149.9,
"valor_previsto": 449.7,
},
)
self.assertIn("Pagamento: em aberto", response)
self.assertIn("testar o comprovante", response)
def test_defer_flow_cancel_when_order_cancel_draft_waits_for_reason(self):
state = FakeState(
entries={
@ -1731,6 +1829,181 @@ 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.assertEqual(registry.calls[0][1]["ordenar_diaria"], "random")
self.assertIn("veiculo(s) para locacao", response)
self.assertIsNotNone(state.get_entry("pending_rental_selections", 21))
self.assertEqual(state.get_user_context(21)["active_domain"], "rental")
async def test_rental_flow_filters_fleet_by_category_when_user_requests_suv(self):
state = FakeState(contexts={21: self._base_context()})
registry = FakeRegistry()
flow = RentalFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_open_rental(
message="quais suv estao disponiveis para aluguel",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "rental_list", "domain": "rental", "action": "answer_user"},
)
self.assertEqual(registry.calls[0][0], "consultar_frota_aluguel")
self.assertEqual(registry.calls[0][1]["categoria"], "suv")
self.assertEqual(registry.calls[0][1]["ordenar_diaria"], "asc")
self.assertIn("veiculo(s) para locacao", response)
async def test_rental_flow_filters_fleet_by_model_when_user_requests_specific_vehicle(self):
state = FakeState(contexts={21: self._base_context()})
registry = FakeRegistry()
flow = RentalFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_open_rental(
message="quero alugar um chevrolet tracker",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "rental_create", "domain": "rental", "action": "answer_user"},
)
self.assertEqual(registry.calls[0][0], "consultar_frota_aluguel")
self.assertEqual(registry.calls[0][1]["modelo"], "Chevrolet Tracker")
self.assertEqual(registry.calls[0][1]["ordenar_diaria"], "asc")
self.assertIn("veiculo(s) para locacao", response)
async def test_rental_flow_ignores_vehicle_year_when_filtering_specific_model(self):
state = FakeState(contexts={21: self._base_context()})
registry = FakeRegistry()
flow = RentalFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_open_rental(
message="quero alugar um fiat pulse 2024",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "rental_create", "domain": "rental", "action": "answer_user"},
)
self.assertEqual(registry.calls[0][0], "consultar_frota_aluguel")
self.assertEqual(registry.calls[0][1]["modelo"], "Fiat Pulse")
self.assertEqual(registry.calls[0][1]["ordenar_diaria"], "asc")
self.assertIn("veiculo(s) para locacao", response)
async def test_rental_flow_keeps_generic_listing_when_request_is_not_a_specific_model(self):
state = FakeState(contexts={21: self._base_context()})
registry = FakeRegistry()
flow = RentalFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_open_rental(
message="quero alugar um carro para viajar com a familia",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "rental_create", "domain": "rental", "action": "answer_user"},
)
self.assertEqual(registry.calls[0][0], "consultar_frota_aluguel")
self.assertNotIn("modelo", registry.calls[0][1])
self.assertEqual(registry.calls[0][1]["ordenar_diaria"], "random")
self.assertIn("veiculo(s) para locacao", response)
async def test_rental_flow_accepts_vehicle_selection_from_list_index(self):
state = FakeState(
entries={
"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):
async def test_review_flow_extracts_relative_datetime_from_followup_message(self):
fixed_now = lambda: datetime(2026, 3, 12, 9, 0)
@ -2920,3 +3193,6 @@ class ToolRegistryExecutionTests(unittest.IsolatedAsyncioTestCase):
if __name__ == "__main__":
unittest.main()

@ -90,7 +90,7 @@ class ConversationHistoryServiceTests(unittest.TestCase):
self.assertEqual(record.conversation_id, "user:7")
self.assertEqual(record.user_id, 7)
self.assertEqual(record.channel, "telegram")
self.assertEqual(record.external_id, "12345")
self.assertEqual(record.external_id, "***345")
self.assertEqual(record.username, "cliente_teste")
self.assertEqual(record.intent, "order_create")
self.assertEqual(record.domain, "sales")
@ -101,6 +101,64 @@ class ConversationHistoryServiceTests(unittest.TestCase):
self.assertEqual(record.completed_at, completed_at)
self.assertEqual(record.elapsed_ms, 512.4)
def test_record_turn_masks_sensitive_fields_before_persisting(self):
session = _FakeSession(
user=SimpleNamespace(
id=7,
channel="telegram",
external_id="987654321",
username="cliente_teste",
)
)
service = ConversationHistoryService()
with patch(
"app.services.orchestration.conversation_history_service.SessionMockLocal",
return_value=session,
):
service.record_turn(
request_id="req-sensitive",
conversation_id="user:7",
user_id=7,
user_message="Meu cpf 123.456.789-09 e a placa ABC1D23.",
assistant_response="Recebi o identificador_comprovante=NSU123 para a placa ABC1D23.",
turn_status="failed",
intent="rental_payment",
domain="rental",
action="call_tool",
tool_name="registrar_pagamento_aluguel",
tool_arguments={
"cpf": "12345678909",
"placa": "ABC1D23",
"external_id": "987654321",
"identificador_comprovante": "NSU123",
"nested": {
"placa": "ABC1D23",
},
},
error_detail='{"external_id":"987654321","placa":"ABC1D23","identificador_comprovante":"NSU123"}',
)
record = session.added[0]
self.assertNotIn("123.456.789-09", record.user_message)
self.assertNotIn("ABC1D23", record.user_message)
self.assertIn("***.***.***-09", record.user_message)
self.assertIn("ABC***3", record.user_message)
self.assertNotIn("NSU123", record.assistant_response)
self.assertIn("***123", record.assistant_response)
self.assertEqual(record.external_id, "******321")
self.assertNotIn("12345678909", record.tool_arguments)
self.assertNotIn("ABC1D23", record.tool_arguments)
self.assertNotIn("987654321", record.tool_arguments)
self.assertNotIn("NSU123", record.tool_arguments)
self.assertIn("***.***.***-09", record.tool_arguments)
self.assertIn("ABC***3", record.tool_arguments)
self.assertIn("******321", record.tool_arguments)
self.assertIn("***123", record.tool_arguments)
self.assertNotIn("987654321", record.error_detail)
self.assertNotIn("ABC1D23", record.error_detail)
self.assertNotIn("NSU123", record.error_detail)
def test_list_turns_filters_and_orders_recent_first(self):
engine = create_engine("sqlite:///:memory:")
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@ -191,6 +249,56 @@ class ConversationHistoryServiceTests(unittest.TestCase):
self.assertEqual(items[0]["tool_arguments"], {"vehicle_id": 1})
self.assertEqual(items[0]["turn_status"], "completed")
self.assertEqual(items[0]["user_id"], user_id)
self.assertEqual(items[0]["external_id"], "***")
def test_list_turns_masks_legacy_sensitive_fields_on_read(self):
engine = create_engine("sqlite:///:memory:")
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
MockBase.metadata.create_all(bind=engine)
self.addCleanup(engine.dispose)
db = SessionLocal()
db.add(
ConversationTurn(
request_id="req-sensitive",
conversation_id="user:42",
user_id=42,
channel="telegram",
external_id="987654321",
username="cliente",
user_message="Cpf 12345678909 e placa ABC1234.",
assistant_response="identificador_comprovante=NSU123 recebido para ABC1234",
turn_status="completed",
tool_name="registrar_pagamento_aluguel",
tool_arguments='{"cpf":"12345678909","placa":"ABC1234","external_id":"987654321","identificador_comprovante":"NSU123"}',
error_detail='{"placa":"ABC1234","external_id":"987654321","identificador_comprovante":"NSU123"}',
started_at=datetime(2026, 3, 16, 13, 0, 0),
completed_at=datetime(2026, 3, 16, 13, 0, 1),
)
)
db.commit()
db.close()
service = ConversationHistoryService()
with patch(
"app.services.orchestration.conversation_history_service.SessionMockLocal",
SessionLocal,
):
items = service.list_turns(request_id="req-sensitive", limit=5)
self.assertEqual(len(items), 1)
item = items[0]
self.assertEqual(item["external_id"], "******321")
self.assertNotIn("12345678909", item["user_message"])
self.assertNotIn("ABC1234", item["user_message"])
self.assertNotIn("NSU123", item["assistant_response"])
self.assertEqual(item["tool_arguments"]["cpf"], "***.***.***-09")
self.assertEqual(item["tool_arguments"]["placa"], "ABC***4")
self.assertEqual(item["tool_arguments"]["external_id"], "******321")
self.assertEqual(item["tool_arguments"]["identificador_comprovante"], "***123")
self.assertNotIn("ABC1234", item["error_detail"])
self.assertNotIn("987654321", item["error_detail"])
self.assertNotIn("NSU123", item["error_detail"])
def test_list_turns_can_filter_by_request_id(self):
engine = create_engine("sqlite:///:memory:")

@ -0,0 +1,39 @@
import unittest
from app.services.orchestration.conversation_state_store import ConversationStateStore
class ConversationStateStoreTests(unittest.TestCase):
def test_save_user_context_preserves_existing_expiration_when_missing(self):
store = ConversationStateStore()
store.upsert_user_context(1, ttl_minutes=30)
original_expires_at = store.get_user_context(1)["expires_at"]
store.save_user_context(
1,
{
"active_domain": "sales",
"active_task": "order_create",
"generic_memory": {},
"shared_memory": {},
"collected_slots": {},
"flow_snapshots": {},
"last_tool_result": None,
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
"last_rental_results": [],
"selected_rental_vehicle": None,
},
)
stored_context = store.get_user_context(1)
self.assertEqual(stored_context["active_domain"], "sales")
self.assertEqual(stored_context["active_task"], "order_create")
self.assertEqual(stored_context["expires_at"], original_expires_at)
if __name__ == "__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))

@ -4,7 +4,11 @@ from types import SimpleNamespace
os.environ.setdefault("DEBUG", "false")
from app.services.ai.llm_service import LLMService
from app.services.ai.llm_service import (
INVALID_RECEIPT_WATERMARK_MESSAGE,
VALID_RECEIPT_WATERMARK_MARKER,
LLMService,
)
class LLMServiceResponseParsingTests(unittest.TestCase):
@ -57,3 +61,44 @@ class LLMServiceResponseParsingTests(unittest.TestCase):
payload = service._extract_response_payload(response)
self.assertEqual(payload, {"response": "Resposta simples", "tool_call": None})
class LLMServiceImageWorkflowPromptTests(unittest.TestCase):
def test_build_image_workflow_prompt_preserves_visible_payment_time(self):
service = LLMService.__new__(LLMService)
prompt = service._build_image_workflow_prompt(caption="Segue o comprovante")
self.assertIn(
"preserve a data e a hora no campo data_pagamento no formato DD/MM/AAAA HH:MM",
prompt,
)
self.assertIn("Nao reduza para somente a data quando a hora estiver visivel.", prompt)
self.assertIn("marca d'agua exatamente escrita como SysaltiIA", prompt)
self.assertIn(
"O comprovante enviado nao e valido. Envie um comprovante valido com a marca d'agua SysaltiIA visivel.",
prompt,
)
self.assertIn(VALID_RECEIPT_WATERMARK_MARKER, prompt)
self.assertIn("Legenda do usuario: Segue o comprovante", prompt)
def test_coerce_image_workflow_response_rejects_payment_without_marker(self):
service = LLMService.__new__(LLMService)
response = service._coerce_image_workflow_response(
"Registrar pagamento de aluguel: contrato LOC-20260319-33CD6567; valor R$ 379,80."
)
self.assertEqual(response, INVALID_RECEIPT_WATERMARK_MESSAGE)
def test_coerce_image_workflow_response_strips_valid_watermark_marker(self):
service = LLMService.__new__(LLMService)
response = service._coerce_image_workflow_response(
f"{VALID_RECEIPT_WATERMARK_MARKER} Registrar pagamento de aluguel: contrato LOC-20260319-33CD6567; valor R$ 379,80."
)
self.assertEqual(
response,
"Registrar pagamento de aluguel: contrato LOC-20260319-33CD6567; valor R$ 379,80.",
)

@ -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,488 @@
import asyncio
import threading
import time
import unittest
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import patch
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
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 RentalLockingQuery:
def __init__(self, result):
self.result = result
self.with_for_update_called = False
def filter(self, *args, **kwargs):
return self
def with_for_update(self):
self.with_for_update_called = True
return self
def first(self):
return self.result
class RentalLockingSession:
def __init__(self, vehicle=None):
self.vehicle = vehicle
self.query_instance = RentalLockingQuery(vehicle)
self.added = []
self.committed = False
self.closed = False
self.refreshed = []
def query(self, model):
if model is rental_service.RentalVehicle:
return self.query_instance
raise AssertionError(f"unexpected model query: {model}")
def add(self, item):
self.added.append(item)
def commit(self):
self.committed = True
def refresh(self, item):
self.refreshed.append(item)
def close(self):
self.closed = True
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 _build_threadsafe_session_local(self):
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
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_consultar_frota_aluguel_filtra_por_modelo(self):
SessionLocal = self._build_session_local()
db = SessionLocal()
try:
self._create_rental_vehicle(db, placa="AAA1A11", modelo="Chevrolet Tracker")
self._create_rental_vehicle(db, placa="BBB2B22", modelo="Fiat Pulse")
finally:
db.close()
with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal):
result = await rental_service.consultar_frota_aluguel(modelo="tracker")
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["modelo"], "Chevrolet Tracker")
async def test_consultar_frota_aluguel_randomiza_resultados_quando_solicitado(self):
SessionLocal = self._build_session_local()
db = SessionLocal()
try:
self._create_rental_vehicle(db, placa="AAA1A11", modelo="Chevrolet Tracker", valor_diaria=219.9)
self._create_rental_vehicle(db, placa="BBB2B22", modelo="Fiat Pulse", valor_diaria=189.9)
self._create_rental_vehicle(db, placa="CCC3C33", modelo="Renault Kwid", valor_diaria=119.9)
finally:
db.close()
with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal), patch(
"app.services.domain.rental_service.random.shuffle",
side_effect=lambda items: items.reverse(),
):
result = await rental_service.consultar_frota_aluguel(ordenar_diaria="random", limite=2)
self.assertEqual(len(result), 2)
self.assertEqual([item["placa"] for item in result], ["CCC3C33", "BBB2B22"])
async def test_abrir_locacao_aluguel_cria_contrato_e_marca_veiculo_como_alugado(self):
SessionLocal = self._build_session_local()
db = SessionLocal()
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_abrir_locacao_aluguel_uses_row_lock_before_reserving_vehicle(self):
vehicle = RentalVehicle(
id=8,
placa="ABC1D23",
modelo="Chevrolet Tracker",
categoria="suv",
ano=2024,
valor_diaria=219.9,
status="disponivel",
)
session = RentalLockingSession(vehicle=vehicle)
fake_uuid = SimpleNamespace(hex="abc123def456")
fixed_now = datetime(2026, 3, 20, 9, 0)
with patch.object(rental_service, "SessionMockLocal", return_value=session), patch.object(
rental_service,
"uuid4",
return_value=fake_uuid,
), patch.object(rental_service, "utc_now", return_value=fixed_now):
result = await rental_service.abrir_locacao_aluguel(
rental_vehicle_id=8,
data_inicio="17/03/2026 10:00",
data_fim_prevista="20/03/2026 10:00",
)
self.assertTrue(session.query_instance.with_for_update_called)
self.assertTrue(session.committed)
self.assertEqual(len(session.added), 1)
self.assertEqual(session.added[0].rental_vehicle_id, 8)
self.assertEqual(vehicle.status, "alugado")
self.assertEqual(result["contrato_numero"], "LOC-20260320-ABC123DE")
self.assertEqual(result["status_veiculo"], "alugado")
self.assertTrue(session.closed)
async def test_abrir_locacao_aluguel_returns_conflict_when_vehicle_status_is_already_rented_after_lock(self):
vehicle = RentalVehicle(
id=8,
placa="ABC1D23",
modelo="Chevrolet Tracker",
categoria="suv",
ano=2024,
valor_diaria=219.9,
status="alugado",
)
session = RentalLockingSession(vehicle=vehicle)
with patch.object(rental_service, "SessionMockLocal", return_value=session):
with self.assertRaises(HTTPException) as ctx:
await rental_service.abrir_locacao_aluguel(
rental_vehicle_id=8,
data_inicio="17/03/2026 10:00",
data_fim_prevista="20/03/2026 10:00",
)
self.assertTrue(session.query_instance.with_for_update_called)
self.assertEqual(ctx.exception.status_code, 409)
self.assertEqual(ctx.exception.detail["code"], "rental_vehicle_unavailable")
self.assertFalse(session.committed)
self.assertEqual(session.added, [])
self.assertTrue(session.closed)
async def test_abrir_locacao_aluguel_allows_single_success_under_race(self):
SessionLocal = self._build_threadsafe_session_local()
db = SessionLocal()
try:
vehicle = self._create_rental_vehicle(db, placa="RAC1E01")
vehicle_id = vehicle.id
finally:
db.close()
attempts = 4
start_barrier = threading.Barrier(attempts)
vehicle_lock = threading.Lock()
def _get_locked_vehicle(db, *, rental_vehicle_id=None, placa=None):
acquired = vehicle_lock.acquire(timeout=2)
if not acquired:
raise AssertionError("failed to acquire rental race lock")
if not db.info.get("_test_rental_lock_wrapped"):
original_close = db.close
def close_with_lock_release():
try:
original_close()
finally:
held_lock = db.info.pop("_test_rental_vehicle_lock", None)
if held_lock and held_lock.locked():
held_lock.release()
db.close = close_with_lock_release
db.info["_test_rental_lock_wrapped"] = True
db.info["_test_rental_vehicle_lock"] = vehicle_lock
time.sleep(0.05)
return rental_service._build_rental_vehicle_query(
db,
rental_vehicle_id=rental_vehicle_id,
placa=placa,
).first()
def _sync_open_rental():
start_barrier.wait(timeout=5)
return asyncio.run(
rental_service.abrir_locacao_aluguel(
rental_vehicle_id=vehicle_id,
data_inicio="17/03/2026 10:00",
data_fim_prevista="20/03/2026 10:00",
)
)
with patch.object(rental_service, "SessionMockLocal", SessionLocal), patch.object(
rental_service,
"_get_rental_vehicle_for_update",
side_effect=_get_locked_vehicle,
):
results = await asyncio.gather(
*[asyncio.to_thread(_sync_open_rental) for _ in range(attempts)],
return_exceptions=True,
)
successes = [result for result in results if isinstance(result, dict)]
conflicts = [
result
for result in results
if isinstance(result, HTTPException)
and isinstance(result.detail, dict)
and result.detail.get("code") == "rental_vehicle_unavailable"
]
unexpected = [result for result in results if result not in successes and result not in conflicts]
self.assertEqual(len(successes), 1)
self.assertEqual(len(conflicts), attempts - 1)
self.assertEqual(unexpected, [])
db = SessionLocal()
try:
contracts = db.query(RentalContract).all()
stored_vehicle = db.query(RentalVehicle).filter(RentalVehicle.id == vehicle_id).one()
self.assertEqual(len(contracts), 1)
self.assertEqual(stored_vehicle.status, "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,234 @@
import asyncio
import threading
import time
import unittest
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import patch
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.mock_database import MockBase
from app.db.mock_models import ReviewSchedule
from app.services.domain import review_service
class ReviewLockingQuery:
def __init__(self, results=None):
self.results = list(results or [])
def filter(self, *args, **kwargs):
return self
def first(self):
if self.results:
return self.results.pop(0)
return None
class ReviewLockingSession:
def __init__(self, *, query_results=None, lock_acquired=1):
self.query_instance = ReviewLockingQuery(query_results)
self.lock_acquired = lock_acquired
self.execute_calls = []
self.added = []
self.committed = False
self.closed = False
self.refreshed = []
def query(self, model):
if model is review_service.ReviewSchedule:
return self.query_instance
raise AssertionError(f"unexpected model query: {model}")
def execute(self, statement, params=None):
sql_text = str(statement)
self.execute_calls.append((sql_text, params))
if "GET_LOCK" in sql_text:
return SimpleNamespace(scalar=lambda: self.lock_acquired)
if "RELEASE_LOCK" in sql_text:
return SimpleNamespace(scalar=lambda: 1)
raise AssertionError(f"unexpected execute call: {sql_text}")
def add(self, item):
self.added.append(item)
def commit(self):
self.committed = True
def refresh(self, item):
self.refreshed.append(item)
def close(self):
self.closed = True
class ReviewServiceLockingTests(unittest.IsolatedAsyncioTestCase):
def _build_threadsafe_session_local(self):
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
MockBase.metadata.create_all(bind=engine)
self.addCleanup(engine.dispose)
return SessionLocal
def test_acquire_review_slot_lock_returns_conflict_when_slot_is_busy(self):
session = ReviewLockingSession(lock_acquired=0)
with self.assertRaises(HTTPException) as ctx:
review_service._acquire_review_slot_lock(
session,
requested_dt=datetime(2026, 3, 18, 9, 0),
)
self.assertEqual(ctx.exception.status_code, 409)
self.assertEqual(ctx.exception.detail["code"], "review_slot_busy")
self.assertTrue(any("GET_LOCK" in sql for sql, _ in session.execute_calls))
async def test_agendar_revisao_uses_slot_lock_and_releases_after_success(self):
session = ReviewLockingSession(query_results=[None, None])
with patch.object(review_service, "SessionMockLocal", return_value=session):
result = await review_service.agendar_revisao(
placa="ABC1234",
data_hora="18/03/2026 09:00",
modelo="Onix",
ano=2022,
km=15000,
revisao_previa_concessionaria=False,
user_id=7,
)
self.assertTrue(any("GET_LOCK" in sql for sql, _ in session.execute_calls))
self.assertTrue(any("RELEASE_LOCK" in sql for sql, _ in session.execute_calls))
self.assertTrue(session.committed)
self.assertEqual(len(session.added), 1)
self.assertEqual(result["status"], "agendado")
self.assertTrue(session.closed)
async def test_editar_data_revisao_releases_slot_lock_when_conflict_is_detected(self):
current_schedule = ReviewSchedule(
id=1,
protocolo="REV-20260318-AAAA1111",
user_id=7,
placa="ABC1234",
data_hora=datetime(2026, 3, 18, 9, 0),
status="agendado",
)
conflicting_schedule = ReviewSchedule(
id=2,
protocolo="REV-20260319-BBBB2222",
user_id=8,
placa="XYZ9876",
data_hora=datetime(2026, 3, 19, 10, 0),
status="agendado",
)
session = ReviewLockingSession(query_results=[current_schedule, conflicting_schedule])
with patch.object(review_service, "SessionMockLocal", return_value=session):
with self.assertRaises(HTTPException) as ctx:
await review_service.editar_data_revisao(
protocolo=current_schedule.protocolo,
nova_data_hora="19/03/2026 10:00",
user_id=7,
)
self.assertTrue(any("GET_LOCK" in sql for sql, _ in session.execute_calls))
self.assertTrue(any("RELEASE_LOCK" in sql for sql, _ in session.execute_calls))
self.assertEqual(ctx.exception.status_code, 409)
self.assertEqual(ctx.exception.detail["code"], "review_schedule_conflict")
self.assertFalse(session.committed)
self.assertTrue(session.closed)
async def test_agendar_revisao_allows_single_success_under_race(self):
SessionLocal = self._build_threadsafe_session_local()
attempts = 4
start_barrier = threading.Barrier(attempts)
slot_locks: dict[str, threading.Lock] = {}
slot_locks_guard = threading.Lock()
def _acquire_slot_lock(db, *, requested_dt, timeout_seconds=5, field_name="data_hora"):
lock_name = review_service._review_slot_lock_name(requested_dt)
with slot_locks_guard:
slot_lock = slot_locks.setdefault(lock_name, threading.Lock())
acquired = slot_lock.acquire(timeout=timeout_seconds)
if not acquired:
review_service.raise_tool_http_error(
status_code=409,
code="review_slot_busy",
message="Outro atendimento esta finalizando este horario de revisao. Tente novamente.",
retryable=True,
field=field_name,
)
db.info.setdefault("_test_review_slot_locks", {})[lock_name] = slot_lock
time.sleep(0.05)
return lock_name
def _release_slot_lock(db, lock_name):
if not lock_name:
return
held_lock = db.info.get("_test_review_slot_locks", {}).pop(lock_name, None)
if held_lock and held_lock.locked():
held_lock.release()
def _sync_schedule_review():
start_barrier.wait(timeout=5)
return asyncio.run(
review_service.agendar_revisao(
placa="ABC1234",
data_hora="18/03/2026 09:00",
modelo="Onix",
ano=2022,
km=15000,
revisao_previa_concessionaria=False,
user_id=7,
)
)
with patch.object(review_service, "SessionMockLocal", SessionLocal), patch.object(
review_service,
"_acquire_review_slot_lock",
side_effect=_acquire_slot_lock,
), patch.object(
review_service,
"_release_review_slot_lock",
side_effect=_release_slot_lock,
):
results = await asyncio.gather(
*[asyncio.to_thread(_sync_schedule_review) for _ in range(attempts)],
return_exceptions=True,
)
successes = [result for result in results if isinstance(result, dict)]
conflict_codes = {"review_schedule_conflict", "review_slot_busy"}
conflicts = [
result
for result in results
if isinstance(result, HTTPException)
and isinstance(result.detail, dict)
and result.detail.get("code") in conflict_codes
]
unexpected = [result for result in results if result not in successes and result not in conflicts]
self.assertEqual(len(successes), 1)
self.assertEqual(len(conflicts), attempts - 1)
self.assertEqual(unexpected, [])
db = SessionLocal()
try:
schedules = db.query(ReviewSchedule).all()
self.assertEqual(len(schedules), 1)
self.assertEqual(schedules[0].status, "agendado")
self.assertEqual(schedules[0].placa, "ABC1234")
finally:
db.close()
if __name__ == "__main__":
unittest.main()

@ -1,7 +1,9 @@
import unittest
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
from app import main as main_module
from app.core.settings import Settings
from app.db import bootstrap as bootstrap_module
from app.db import init_db as init_db_module
@ -25,35 +27,35 @@ class SettingsParsingTests(unittest.TestCase):
self.assertEqual(settings.conversation_state_backend, "redis")
class InitDbBootstrapTests(unittest.TestCase):
@patch.object(init_db_module, "seed_tools")
@patch.object(init_db_module, "seed_mock_data")
@patch.object(init_db_module.MockBase.metadata, "create_all")
@patch.object(init_db_module.Base.metadata, "create_all")
def test_init_db_respects_seed_flags(
class BootstrapRuntimeTests(unittest.TestCase):
@patch.object(bootstrap_module, "seed_tools")
@patch.object(bootstrap_module, "seed_mock_data")
@patch.object(bootstrap_module.MockBase.metadata, "create_all")
@patch.object(bootstrap_module.Base.metadata, "create_all")
def test_bootstrap_databases_respects_seed_flags(
self,
tools_create_all,
mock_create_all,
seed_mock_data,
seed_tools,
):
with patch.object(init_db_module.settings, "auto_seed_tools", False), patch.object(
init_db_module.settings,
with patch.object(bootstrap_module.settings, "auto_seed_tools", False), patch.object(
bootstrap_module.settings,
"auto_seed_mock",
False,
), patch.object(init_db_module.settings, "mock_seed_enabled", True):
init_db_module.init_db()
), patch.object(bootstrap_module.settings, "mock_seed_enabled", True):
bootstrap_module.bootstrap_databases()
tools_create_all.assert_called_once()
mock_create_all.assert_called_once()
seed_tools.assert_not_called()
seed_mock_data.assert_not_called()
@patch.object(init_db_module, "seed_tools")
@patch.object(init_db_module, "seed_mock_data")
@patch.object(init_db_module.MockBase.metadata, "create_all")
@patch.object(init_db_module.Base.metadata, "create_all", side_effect=RuntimeError("tools db down"))
def test_init_db_raises_when_any_backend_fails(
@patch.object(bootstrap_module, "seed_tools")
@patch.object(bootstrap_module, "seed_mock_data")
@patch.object(bootstrap_module.MockBase.metadata, "create_all")
@patch.object(bootstrap_module.Base.metadata, "create_all", side_effect=RuntimeError("tools db down"))
def test_bootstrap_databases_raises_when_any_backend_fails(
self,
tools_create_all,
mock_create_all,
@ -61,13 +63,31 @@ class InitDbBootstrapTests(unittest.TestCase):
seed_tools,
):
with self.assertRaisesRegex(RuntimeError, "tools=tools db down"):
init_db_module.init_db()
bootstrap_module.bootstrap_databases()
tools_create_all.assert_called_once()
mock_create_all.assert_called_once()
seed_mock_data.assert_called_once()
seed_tools.assert_not_called()
@patch.object(init_db_module, "bootstrap_databases")
def test_init_db_wrapper_delegates_to_bootstrap_databases(self, bootstrap_databases):
init_db_module.init_db()
bootstrap_databases.assert_called_once_with()
class HttpStartupTests(unittest.IsolatedAsyncioTestCase):
async def test_startup_event_warms_llm_without_running_bootstrap(self):
with patch("app.main.LLMService") as llm_cls, patch(
"app.db.bootstrap.bootstrap_databases"
) as bootstrap_databases:
llm_cls.return_value.warmup = AsyncMock()
await main_module.startup_event()
llm_cls.return_value.warmup.assert_awaited_once()
bootstrap_databases.assert_not_called()
if __name__ == "__main__":
unittest.main()

@ -0,0 +1,467 @@
import unittest
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import aiohttp
from fastapi import HTTPException
from app.integrations.telegram_satellite_service import (
TELEGRAM_RUNTIME_BUCKET,
TELEGRAM_RUNTIME_OWNER_ID,
TelegramSatelliteService,
)
from app.services.orchestration.conversation_state_store import ConversationStateStore
class _DummySession:
def close(self):
return None
class _FakeTelegramResponse:
def __init__(self, payload):
self.payload = payload
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
async def json(self):
return self.payload
class _FakeTelegramSession:
def __init__(self, payload):
self.payload = payload
self.calls = []
def post(self, url, json):
self.calls.append((url, json))
return _FakeTelegramResponse(self.payload)
class _FlakyTelegramResponse:
def __init__(self, outcome):
self.outcome = outcome
async def __aenter__(self):
if isinstance(self.outcome, BaseException):
raise self.outcome
return self
async def __aexit__(self, exc_type, exc, tb):
return None
async def json(self):
return self.outcome
class _FlakyTelegramSession:
def __init__(self, outcomes):
self.outcomes = list(outcomes)
self.calls = []
def post(self, url, json):
self.calls.append((url, json))
if self.outcomes:
outcome = self.outcomes.pop(0)
else:
outcome = {"ok": True}
return _FlakyTelegramResponse(outcome)
class TelegramMultimodalTests(unittest.IsolatedAsyncioTestCase):
def _build_service(self) -> TelegramSatelliteService:
service = TelegramSatelliteService(
"token-teste",
state_repository=ConversationStateStore(),
)
self._service_under_test = service
return service
async def asyncTearDown(self):
service = getattr(self, "_service_under_test", None)
if service is not None:
await service._shutdown_chat_workers()
async def test_process_message_uses_extracted_image_message(self):
service = self._build_service()
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 = self._build_service()
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)
async def test_process_message_returns_direct_failure_for_receipt_without_watermark(self):
service = self._build_service()
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="O comprovante enviado nao e valido. Envie um comprovante valido com a marca d'agua SysaltiIA visivel."),
):
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="segue o comprovante",
sender={"id": 99},
chat_id=99,
image_attachments=[{"mime_type": "image/jpeg", "data": b"123"}],
)
self.assertIn("marca d'agua SysaltiIA visivel", answer)
self.assertFalse(orchestrator_cls.return_value.handle_message.await_count)
async def test_process_message_offloads_blocking_turn_to_worker_thread(self):
service = self._build_service()
with patch(
"app.integrations.telegram_satellite_service.asyncio.to_thread",
AsyncMock(return_value="ok"),
) as to_thread:
answer = await service._process_message(
text="quero ver a frota",
sender={"id": 99, "first_name": "Vitor"},
chat_id=99,
image_attachments=[],
)
self.assertEqual(answer, "ok")
self.assertEqual(to_thread.await_count, 1)
self.assertEqual(to_thread.await_args.kwargs["message_text"], "quero ver a frota")
self.assertEqual(to_thread.await_args.kwargs["chat_id"], 99)
self.assertEqual(to_thread.await_args.kwargs["sender"]["id"], 99)
async def test_handle_update_masks_sensitive_domain_error_in_logs(self):
service = self._build_service()
update = {
"update_id": 1,
"message": {
"chat": {"id": 99},
"from": {"id": 99},
"text": "segue o pagamento",
},
}
with patch.object(service, "_extract_image_attachments", AsyncMock(return_value=[])), patch.object(
service,
"_process_message",
AsyncMock(
side_effect=HTTPException(
status_code=409,
detail={
"cpf": "12345678909",
"placa": "ABC1D23",
"external_id": "987654321",
"identificador_comprovante": "NSU123",
},
)
),
), patch.object(service, "_send_message", AsyncMock()), patch(
"app.integrations.telegram_satellite_service.logger.warning"
) as logger_warning:
await service._handle_update(session=SimpleNamespace(), update=update)
self.assertTrue(logger_warning.called)
logged_detail = str(logger_warning.call_args.args[1])
self.assertNotIn("12345678909", logged_detail)
self.assertNotIn("ABC1D23", logged_detail)
self.assertNotIn("987654321", logged_detail)
self.assertNotIn("NSU123", logged_detail)
self.assertIn("***.***.***-09", logged_detail)
self.assertIn("ABC***3", logged_detail)
self.assertIn("******321", logged_detail)
self.assertIn("***123", logged_detail)
async def test_handle_update_reuses_cached_answer_for_duplicate_message(self):
service = self._build_service()
update = {
"update_id": 10,
"message": {
"message_id": 77,
"chat": {"id": 99},
"from": {"id": 99},
"text": "quero ver a frota",
},
}
with patch.object(service, "_extract_image_attachments", AsyncMock(return_value=[])), patch.object(
service,
"_process_message",
AsyncMock(return_value="Segue a frota disponivel."),
) as process_message, patch.object(service, "_send_message", AsyncMock()) as send_message:
await service._handle_update(session=SimpleNamespace(), update=update)
await service._handle_update(session=SimpleNamespace(), update=update)
self.assertEqual(process_message.await_count, 1)
self.assertEqual(send_message.await_count, 2)
first_text = send_message.await_args_list[0].kwargs["text"]
second_text = send_message.await_args_list[1].kwargs["text"]
self.assertEqual(first_text, "Segue a frota disponivel.")
self.assertEqual(second_text, "Segue a frota disponivel.")
async def test_handle_update_processes_same_text_again_when_message_id_changes(self):
service = self._build_service()
first_update = {
"update_id": 10,
"message": {
"message_id": 77,
"chat": {"id": 99},
"from": {"id": 99},
"text": "quero ver a frota",
},
}
second_update = {
"update_id": 11,
"message": {
"message_id": 78,
"chat": {"id": 99},
"from": {"id": 99},
"text": "quero ver a frota",
},
}
with patch.object(service, "_extract_image_attachments", AsyncMock(return_value=[])), patch.object(
service,
"_process_message",
AsyncMock(side_effect=["Resposta 1", "Resposta 2"]),
) as process_message, patch.object(service, "_send_message", AsyncMock()) as send_message:
await service._handle_update(session=SimpleNamespace(), update=first_update)
await service._handle_update(session=SimpleNamespace(), update=second_update)
self.assertEqual(process_message.await_count, 2)
self.assertEqual(send_message.await_count, 2)
self.assertEqual(send_message.await_args_list[0].kwargs["text"], "Resposta 1")
self.assertEqual(send_message.await_args_list[1].kwargs["text"], "Resposta 2")
async def test_initialize_offset_uses_persisted_cursor(self):
service = self._build_service()
service.state.set_entry(
TELEGRAM_RUNTIME_BUCKET,
TELEGRAM_RUNTIME_OWNER_ID,
{"last_update_id": 41},
)
offset = await service._initialize_offset(session=SimpleNamespace())
self.assertEqual(offset, 42)
self.assertEqual(service._last_update_id, 41)
async def test_initialize_offset_bootstraps_cursor_once_when_missing(self):
service = self._build_service()
session = _FakeTelegramSession(
{
"ok": True,
"result": [
{"update_id": 5},
{"update_id": 6},
],
}
)
offset = await service._initialize_offset(session=session)
self.assertEqual(offset, 7)
self.assertEqual(service._last_update_id, 6)
entry = service.state.get_entry(TELEGRAM_RUNTIME_BUCKET, TELEGRAM_RUNTIME_OWNER_ID)
self.assertEqual(entry["last_update_id"], 6)
self.assertEqual(len(session.calls), 1)
async def test_handle_update_persists_runtime_cursor(self):
service = self._build_service()
update = {
"update_id": 14,
"message": {
"message_id": 88,
"chat": {"id": 99},
"from": {"id": 99},
"text": "status do pedido",
},
}
with patch.object(service, "_extract_image_attachments", AsyncMock(return_value=[])), patch.object(
service,
"_process_message",
AsyncMock(return_value="Pedido encontrado."),
), patch.object(service, "_send_message", AsyncMock()):
await service._handle_update(session=SimpleNamespace(), update=update)
entry = service.state.get_entry(TELEGRAM_RUNTIME_BUCKET, TELEGRAM_RUNTIME_OWNER_ID)
self.assertEqual(entry["last_update_id"], 14)
async def test_send_message_retries_transient_transport_failure(self):
service = self._build_service()
session = _FlakyTelegramSession(
[
asyncio.TimeoutError(),
aiohttp.ClientConnectionError("falha temporaria"),
{"ok": True},
]
)
with patch("app.integrations.telegram_satellite_service.asyncio.sleep", AsyncMock()) as sleep_mock:
await service._send_message(session=session, chat_id=99, text="resposta teste")
self.assertEqual(len(session.calls), 3)
self.assertEqual(sleep_mock.await_count, 2)
async def test_handle_update_swallows_unexpected_delivery_failure(self):
service = self._build_service()
update = {
"update_id": 15,
"message": {
"message_id": 89,
"chat": {"id": 99},
"from": {"id": 99},
"text": "status do pedido",
},
}
with patch.object(service, "_extract_image_attachments", AsyncMock(return_value=[])), patch.object(
service,
"_process_message",
AsyncMock(return_value="Pedido encontrado."),
), patch.object(
service,
"_send_message",
AsyncMock(side_effect=RuntimeError("falha inesperada de entrega")),
), patch("app.integrations.telegram_satellite_service.logger.exception") as logger_exception:
await service._handle_update(session=SimpleNamespace(), update=update)
self.assertTrue(logger_exception.called)
async def test_persist_last_processed_update_id_keeps_highest_seen_value(self):
service = self._build_service()
service._persist_last_processed_update_id(11)
service._persist_last_processed_update_id(10)
entry = service.state.get_entry(TELEGRAM_RUNTIME_BUCKET, TELEGRAM_RUNTIME_OWNER_ID)
self.assertEqual(entry["last_update_id"], 11)
self.assertEqual(service._last_update_id, 11)
async def test_schedule_update_processing_allows_parallel_chats(self):
service = self._build_service()
release_first_chat = asyncio.Event()
chat_one_started = asyncio.Event()
started_chats: list[int] = []
async def fake_handle_update(*, session, update):
chat_id = update["message"]["chat"]["id"]
started_chats.append(chat_id)
if chat_id == 1:
chat_one_started.set()
await release_first_chat.wait()
with patch.object(service, "_handle_update", new=fake_handle_update):
await service._schedule_update_processing(
session=SimpleNamespace(),
update={"update_id": 1, "message": {"chat": {"id": 1}, "text": "primeiro"}},
)
await chat_one_started.wait()
await service._schedule_update_processing(
session=SimpleNamespace(),
update={"update_id": 2, "message": {"chat": {"id": 2}, "text": "segundo"}},
)
await asyncio.sleep(0)
self.assertEqual(started_chats, [1, 2])
release_first_chat.set()
await asyncio.sleep(0)
async def test_schedule_update_processing_preserves_order_per_chat(self):
service = self._build_service()
first_started = asyncio.Event()
allow_first_to_finish = asyncio.Event()
second_started = asyncio.Event()
started_updates: list[int] = []
async def fake_handle_update(*, session, update):
update_id = update["update_id"]
started_updates.append(update_id)
if update_id == 1:
first_started.set()
await allow_first_to_finish.wait()
return
second_started.set()
with patch.object(service, "_handle_update", new=fake_handle_update):
await service._schedule_update_processing(
session=SimpleNamespace(),
update={"update_id": 1, "message": {"chat": {"id": 1}, "text": "primeiro"}},
)
await first_started.wait()
await service._schedule_update_processing(
session=SimpleNamespace(),
update={"update_id": 2, "message": {"chat": {"id": 1}, "text": "segundo"}},
)
await asyncio.sleep(0)
self.assertFalse(second_started.is_set())
allow_first_to_finish.set()
await asyncio.wait_for(second_started.wait(), timeout=1)
self.assertEqual(started_updates, [1, 2])

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save