diff --git a/DEPLOY_SERVIDOR.md b/DEPLOY_SERVIDOR.md index 21bfa0a..b1c900c 100644 --- a/DEPLOY_SERVIDOR.md +++ b/DEPLOY_SERVIDOR.md @@ -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 ``` + diff --git a/Dockerfile b/Dockerfile index 7fbbfe4..2868940 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 3eea69e..3d7e9d9 100644 --- a/README.md +++ b/README.md @@ -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 + + diff --git a/app/db/bootstrap.py b/app/db/bootstrap.py new file mode 100644 index 0000000..5b7f89b --- /dev/null +++ b/app/db/bootstrap.py @@ -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() diff --git a/app/db/init_db.py b/app/db/init_db.py index 3e7c01b..ef55d59 100644 --- a/app/db/init_db.py +++ b/app/db/init_db.py @@ -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__": diff --git a/app/db/mock_models.py b/app/db/mock_models.py index 9193425..fb7b331 100644 --- a/app/db/mock_models.py +++ b/app/db/mock_models.py @@ -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" diff --git a/app/db/mock_seed.py b/app/db/mock_seed.py index 4133271..b6ebffe 100644 --- a/app/db/mock_seed.py +++ b/app/db/mock_seed.py @@ -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() - diff --git a/app/db/tool_seed.py b/app/db/tool_seed.py index c447618..d289d19 100644 --- a/app/db/tool_seed.py +++ b/app/db/tool_seed.py @@ -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": ( diff --git a/app/integrations/telegram_satellite_service.py b/app/integrations/telegram_satellite_service.py index 532e674..8defeb9 100644 --- a/app/integrations/telegram_satellite_service.py +++ b/app/integrations/telegram_satellite_service.py @@ -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,18 +161,194 @@ class TelegramSatelliteService: timeout = aiohttp.ClientTimeout(total=self.request_timeout) async with aiohttp.ClientSession(timeout=timeout) as session: - offset = await self._initialize_offset(session=session) + try: + offset = await self._initialize_offset(session=session) + while True: + updates = await self._get_updates(session=session, offset=offset) + for update in updates: + update_id = update.get("update_id") + if not isinstance(update_id, int): + continue + if update_id <= self._last_update_id: + 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: - updates = await self._get_updates(session=session, offset=offset) - for update in updates: - update_id = update.get("update_id") - if not isinstance(update_id, int): - continue - if update_id <= self._last_update_id: - continue - self._last_update_id = update_id - offset = update_id + 1 - await self._handle_update(session=session, update=update) + 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, } - 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) - - async def _process_message(self, text: str, sender: Dict[str, Any], chat_id: int) -> str: + 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) + + # 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()) + diff --git a/app/main.py b/app/main.py index 2b31d93..bc79137 100644 --- a/app/main.py +++ b/app/main.py @@ -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.") diff --git a/app/services/ai/llm_service.py b/app/services/ai/llm_service.py index 7d88e01..8581251 100644 --- a/app/services/ai/llm_service.py +++ b/app/services/ai/llm_service.py @@ -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. diff --git a/app/services/domain/rental_service.py b/app/services/domain/rental_service.py new file mode 100644 index 0000000..dc9b6cd --- /dev/null +++ b/app/services/domain/rental_service.py @@ -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() diff --git a/app/services/domain/review_service.py b/app/services/domain/review_service.py index 07cc757..ef0a210 100644 --- a/app/services/domain/review_service.py +++ b/app/services/domain/review_service.py @@ -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() diff --git a/app/services/flows/flow_state_support.py b/app/services/flows/flow_state_support.py new file mode 100644 index 0000000..2bd98ec --- /dev/null +++ b/app/services/flows/flow_state_support.py @@ -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 diff --git a/app/services/flows/order_flow.py b/app/services/flows/order_flow.py index 3975037..237ae35 100644 --- a/app/services/flows/order_flow.py +++ b/app/services/flows/order_flow.py @@ -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) + diff --git a/app/services/flows/order_flow_support.py b/app/services/flows/order_flow_support.py new file mode 100644 index 0000000..953a822 --- /dev/null +++ b/app/services/flows/order_flow_support.py @@ -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) diff --git a/app/services/flows/rental_flow.py b/app/services/flows/rental_flow.py new file mode 100644 index 0000000..4fa0122 --- /dev/null +++ b/app/services/flows/rental_flow.py @@ -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"(? str | None: + normalized = self._normalize_text(text).strip() + if not normalized: + return None + + normalized = re.sub(r"\b\d{1,2}[/-]\d{1,2}[/-]\d{4}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?\b", " ", normalized) + normalized = re.sub(r"\b\d{4}[/-]\d{1,2}[/-]\d{1,2}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?\b", " ", normalized) + normalized = re.sub(r"\b[a-z]{3}\d[a-z0-9]\d{2}\b", " ", normalized) + normalized = re.sub(r"\br\$\s*\d+[\d\.,]*\b", " ", normalized) + + category = self._extract_rental_category_from_text(normalized) + if category: + normalized = re.sub(rf"(?.+)", + r"(?:tem|ha|existe|existem|mostre|mostrar|liste|listar|quais)\s+(?:um|uma|o|a)?\s*(?P.+)", + r"(?P.+?)\s+(?:para\s+aluguel|para\s+locacao)\b", + ) + for pattern in cue_patterns: + match = re.search(pattern, normalized) + if match: + candidate = str(match.group("candidate") or "").strip() + if candidate: + break + if not candidate: + return None + + boundary_tokens = { + "para", + "pra", + "com", + "sem", + "que", + "por", + "de", + "do", + "da", + "dos", + "das", + "no", + "na", + "nos", + "nas", + "automatico", + "automatica", + "automaticos", + "automaticas", + "manual", + "manuais", + "barato", + "barata", + "economico", + "economica", + } + generic_tokens = { + "aluguel", + "alugar", + "locacao", + "locar", + "carro", + "carros", + "veiculo", + "veiculos", + "modelo", + "categoria", + "tipo", + "disponiveis", + "disponivel", + "frota", + "opcoes", + "opcao", + "esta", + "estao", + "estava", + "estavam", + "existe", + "existem", + "ha", + "tem", + "um", + "uma", + "o", + "a", + "os", + "as", + "suv", + "sedan", + "hatch", + "pickup", + "picape", + } + + tokens: list[str] = [] + for token in re.findall(r"[a-z0-9]+", candidate): + if token in boundary_tokens: + break + if token in generic_tokens: + continue + if re.fullmatch(r"(?:19|20)\d{2}", token): + continue + if len(token) < 2: + continue + tokens.append(token) + if len(tokens) >= 3: + break + + if not tokens: + return None + + return " ".join(tokens).title().strip() or None + + # Coleta datas de locacao em texto livre mantendo a ordem encontrada. + def _extract_rental_datetimes_from_text(self, text: str) -> list[str]: + normalized = technical_normalizer.normalize_datetime_connector(text) + patterns = ( + r"\b\d{1,2}[/-]\d{1,2}[/-]\d{4}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?\b", + r"\b\d{4}[/-]\d{1,2}[/-]\d{1,2}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?\b", + ) + results: list[str] = [] + for pattern in patterns: + for match in re.finditer(pattern, normalized): + candidate = self._normalize_rental_datetime_text(match.group(0)) + if candidate and candidate not in results: + results.append(candidate) + return results + + # Normaliza datas de locacao para um formato unico aceito pelo fluxo. + def _normalize_rental_datetime_text(self, value) -> str | None: + text = technical_normalizer.normalize_datetime_connector(str(value or "").strip()) + if not text: + return None + + parsed = technical_normalizer.try_parse_iso_datetime(text) + if parsed is None: + parsed = technical_normalizer.try_parse_datetime_with_formats( + text, + ( + "%d/%m/%Y %H:%M", + "%d/%m/%Y %H:%M:%S", + "%d/%m/%Y", + "%Y-%m-%d %H:%M", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d", + ), + ) + if parsed is None: + return None + if ":" in text: + return parsed.strftime("%d/%m/%Y %H:%M") + return parsed.strftime("%d/%m/%Y") + + # Normaliza campos estruturados de aluguel antes de montar o draft. + def _normalize_rental_fields(self, data) -> dict: + if not isinstance(data, dict): + return {} + + payload: dict = {} + + rental_vehicle_id = data.get("rental_vehicle_id") + if rental_vehicle_id is None: + rental_vehicle_id = data.get("vehicle_id") + try: + if rental_vehicle_id not in (None, ""): + numeric = int(rental_vehicle_id) + if numeric > 0: + payload["rental_vehicle_id"] = numeric + except (TypeError, ValueError): + pass + + placa = technical_normalizer.normalize_plate(data.get("placa")) + if placa: + payload["placa"] = placa + + cpf = technical_normalizer.normalize_cpf(data.get("cpf")) + if cpf: + payload["cpf"] = cpf + + valor_diaria_max = technical_normalizer.normalize_positive_number(data.get("valor_diaria_max")) + if valor_diaria_max: + payload["valor_diaria_max"] = float(valor_diaria_max) + + categoria = self._extract_rental_category_from_text(str(data.get("categoria") or "")) + if categoria: + payload["categoria"] = categoria + + model_hint = str(data.get("modelo") or data.get("modelo_veiculo") or "").strip(" ,.;") + if model_hint and not self._extract_rental_category_from_text(model_hint): + payload["modelo"] = model_hint.title() + + for field_name in ("data_inicio", "data_fim_prevista"): + normalized = self._normalize_rental_datetime_text(data.get(field_name)) + if normalized: + payload[field_name] = normalized + + return payload + + # Enriquece o draft com placa, cpf, categoria, budget e datas extraidos da mensagem. + def _try_capture_rental_fields_from_message(self, message: str, payload: dict) -> None: + if payload.get("placa") is None: + words = re.findall(r"[A-Za-z0-9-]+", str(message or "")) + for word in words: + plate = technical_normalizer.normalize_plate(word) + if plate: + payload["placa"] = plate + break + + if payload.get("cpf") is None: + cpf = technical_normalizer.extract_cpf_from_text(message) + if cpf and technical_normalizer.is_valid_cpf(cpf): + payload["cpf"] = cpf + + if payload.get("categoria") is None: + category = self._extract_rental_category_from_text(message) + if category: + payload["categoria"] = category + + if payload.get("valor_diaria_max") is None: + budget = technical_normalizer.extract_budget_from_text(message) + if budget: + payload["valor_diaria_max"] = float(budget) + + if payload.get("modelo") is None: + model_hint = self._extract_rental_model_from_text(message) + if model_hint: + payload["modelo"] = model_hint + + datetimes = self._extract_rental_datetimes_from_text(message) + if datetimes: + if not payload.get("data_inicio"): + payload["data_inicio"] = datetimes[0] + if len(datetimes) >= 2 and not payload.get("data_fim_prevista"): + payload["data_fim_prevista"] = datetimes[1] + elif len(datetimes) == 1 and payload.get("data_inicio") and not payload.get("data_fim_prevista"): + if payload["data_inicio"] != datetimes[0]: + payload["data_fim_prevista"] = datetimes[0] + + # Detecta pedidos para listar a frota de aluguel. + def _has_rental_listing_request(self, message: str, turn_decision: dict | None = None) -> bool: + decision_intent = self._decision_intent(turn_decision) + decision_domain = str((turn_decision or {}).get("domain") or "").strip().lower() + if decision_domain == "rental" and decision_intent in {"rental_list", "rental_search"}: + return True + normalized = self._normalize_text(message).strip() + rental_terms = {"aluguel", "alugar", "locacao", "locar"} + listing_terms = {"quais", "listar", "liste", "mostrar", "mostre", "disponiveis", "disponivel", "frota", "opcoes", "opcao"} + return any(term in normalized for term in rental_terms) and any(term in normalized for term in listing_terms) + + # Detecta quando o usuario quer iniciar uma nova locacao. + def _has_explicit_rental_request(self, message: str) -> bool: + normalized = self._normalize_text(message).strip() + if any(term in normalized for term in {"multa", "comprovante", "pagamento", "devolucao", "devolver"}): + return False + request_terms = { + "quero alugar", + "quero locar", + "abrir locacao", + "abrir aluguel", + "fazer locacao", + "iniciar locacao", + "seguir com a locacao", + "seguir com aluguel", + "alugar o carro", + "locacao do carro", + } + return any(term in normalized for term in request_terms) + + # Detecta pedidos de devolucao ou encerramento da locacao. + def _has_rental_return_request(self, message: str) -> bool: + normalized = self._normalize_text(message).strip() + return any(term in normalized for term in {"devolver", "devolucao", "encerrar locacao", "fechar locacao"}) + + # Detecta quando a mensagem parece tratar de pagamento ou multa de aluguel. + def _has_rental_payment_or_fine_request(self, message: str) -> bool: + normalized = self._normalize_text(message).strip() + return any(term in normalized for term in {"multa", "comprovante", "pagamento", "boleto", "pix"}) + + # Interpreta selecoes numericas com base na ultima lista apresentada. + def _match_rental_vehicle_from_message_index(self, message: str, rental_results: list[dict]) -> dict | None: + tokens = [token for token in re.findall(r"\d+", str(message or "")) if token.isdigit()] + if not tokens: + return None + choice = int(tokens[0]) + if 1 <= choice <= len(rental_results): + return rental_results[choice - 1] + return None + + # Tenta casar a resposta do usuario com modelo ou placa da frota mostrada. + def _match_rental_vehicle_from_message_model(self, message: str, rental_results: list[dict]) -> dict | None: + normalized_message = self._normalize_text(message) + matches = [] + for item in rental_results: + normalized_model = self._normalize_text(str(item.get("modelo") or "")) + normalized_plate = self._normalize_text(str(item.get("placa") or "")) + if (normalized_model and normalized_model in normalized_message) or ( + normalized_plate and normalized_plate in normalized_message + ): + matches.append(item) + if len(matches) == 1: + return matches[0] + return None + + # Resolve o veiculo escolhido reaproveitando contexto e texto livre. + def _try_resolve_rental_vehicle(self, message: str, user_id: int | None, payload: dict) -> dict | None: + rental_vehicle_id = payload.get("rental_vehicle_id") + if isinstance(rental_vehicle_id, int) and rental_vehicle_id > 0: + for item in self._get_last_rental_results(user_id=user_id): + if int(item.get("id") or 0) == rental_vehicle_id: + return item + + rental_results = self._get_last_rental_results(user_id=user_id) + selected_from_model = self._match_rental_vehicle_from_message_model(message=message, rental_results=rental_results) + if selected_from_model: + return selected_from_model + + selected_from_index = self._match_rental_vehicle_from_message_index(message=message, rental_results=rental_results) + if selected_from_index: + return selected_from_index + + normalized_plate = technical_normalizer.normalize_plate(payload.get("placa")) + if normalized_plate: + matches = [item for item in rental_results if str(item.get("placa") or "").strip().upper() == normalized_plate] + if len(matches) == 1: + return matches[0] + + return None + + # Decide se a mensagem atual pode continuar uma selecao de aluguel ja iniciada. + def _should_bootstrap_rental_from_context(self, message: str, user_id: int | None, payload: dict | None = None) -> bool: + if user_id is None: + return False + + rental_results = self._get_last_rental_results(user_id=user_id) + if not rental_results: + return False + + normalized_payload = payload if isinstance(payload, dict) else {} + return bool( + self._match_rental_vehicle_from_message_model(message=message, rental_results=rental_results) + or self._match_rental_vehicle_from_message_index(message=message, rental_results=rental_results) + or ( + normalized_payload.get("placa") + and self._try_resolve_rental_vehicle(message=message, user_id=user_id, payload=normalized_payload) + ) + ) + + # Monta a pergunta objetiva com os campos que faltam para abrir a locacao. + def _render_missing_rental_fields_prompt(self, missing_fields: list[str]) -> str: + labels = { + "rental_vehicle_id": "qual veiculo da frota voce quer alugar", + "data_inicio": "a data e hora de inicio da locacao", + "data_fim_prevista": "a data e hora previstas para devolucao", + } + items = [f"- {labels[field]}" for field in missing_fields] + return "Para abrir a locacao, preciso dos dados abaixo:\n" + "\n".join(items) + + # Formata a lista curta da frota para o usuario escolher um veiculo. + def _render_rental_selection_from_fleet_prompt(self, rental_results: list[dict]) -> str: + lines = ["Para seguir com a locacao, escolha primeiro qual veiculo voce quer alugar:"] + for idx, item in enumerate(rental_results[:10], start=1): + lines.append( + f"- {idx}. {item.get('modelo', 'N/A')} {item.get('ano', 'N/A')} | " + f"{item.get('placa', 'N/A')} | {item.get('categoria', 'N/A')} | " + f"diaria R$ {float(item.get('valor_diaria', 0)):.2f}" + ) + lines.append("Pode responder com o numero da lista, com a placa ou com o modelo.") + return "\n".join(lines) + + # Consulta a frota e guarda o resultado para a etapa de selecao. + async def _try_list_rental_fleet_for_selection( + self, + message: str, + user_id: int | None, + payload: dict, + turn_decision: dict | None = None, + force: bool = False, + ) -> str | None: + if user_id is None: + return None + if not force and not self._has_rental_listing_request(message, turn_decision=turn_decision): + return None + + arguments: dict = { + "limite": 10, + } + category = payload.get("categoria") or self._extract_rental_category_from_text(message) + if category: + arguments["categoria"] = str(category).strip().lower() + + model_hint = str(payload.get("modelo") or self._extract_rental_model_from_text(message) or "").strip() + if model_hint: + arguments["modelo"] = model_hint + + arguments["ordenar_diaria"] = "asc" if (category or model_hint) else "random" + + valor_diaria_max = payload.get("valor_diaria_max") + if not isinstance(valor_diaria_max, (int, float)): + valor_diaria_max = technical_normalizer.extract_budget_from_text(message) + if isinstance(valor_diaria_max, (int, float)) and float(valor_diaria_max) > 0: + arguments["valor_diaria_max"] = float(valor_diaria_max) + + try: + tool_result = await self.tool_executor.execute( + "consultar_frota_aluguel", + arguments, + user_id=user_id, + ) + except HTTPException as exc: + return self._http_exception_detail(exc) + + rental_results = tool_result if isinstance(tool_result, list) else [] + self._remember_rental_results(user_id=user_id, rental_results=rental_results) + self._mark_rental_flow_active(user_id=user_id) + return self._fallback_format_tool_result("consultar_frota_aluguel", tool_result) + + # Conduz a coleta incremental dos dados e abre a locacao quando estiver completa. + async def _try_collect_and_open_rental( + self, + message: str, + user_id: int | None, + extracted_fields: dict | None = None, + intents: dict | None = None, + turn_decision: dict | None = None, + ) -> str | None: + if user_id is None: + return None + + draft = self.state.get_entry("pending_rental_drafts", user_id, expire=True) + extracted = self._normalize_rental_fields(extracted_fields) + decision_intent = self._decision_intent(turn_decision) + has_intent = decision_intent in {"rental_create", "rental_list", "rental_search"} + explicit_rental_request = self._has_explicit_rental_request(message) + rental_listing_request = self._has_rental_listing_request(message, turn_decision=turn_decision) + should_bootstrap_from_context = draft is None and self._should_bootstrap_rental_from_context( + message=message, + user_id=user_id, + payload=extracted, + ) + + if ( + draft is None + and not has_intent + and not explicit_rental_request + and not rental_listing_request + and not should_bootstrap_from_context + ): + return None + + if draft is None: + draft = { + "payload": {}, + "expires_at": utc_now() + timedelta(minutes=PENDING_RENTAL_DRAFT_TTL_MINUTES), + } + + draft_payload = draft.get("payload", {}) + if not isinstance(draft_payload, dict): + draft_payload = {} + draft["payload"] = draft_payload + + draft_payload.update(extracted) + self._try_capture_rental_fields_from_message(message=message, payload=draft_payload) + + selected_vehicle = self._get_selected_rental_vehicle(user_id=user_id) + if selected_vehicle and not draft_payload.get("rental_vehicle_id"): + draft_payload.update(self._rental_vehicle_to_payload(selected_vehicle)) + + resolved_vehicle = self._try_resolve_rental_vehicle( + message=message, + user_id=user_id, + payload=draft_payload, + ) + if resolved_vehicle: + self._store_selected_rental_vehicle(user_id=user_id, vehicle=resolved_vehicle) + draft_payload.update(self._rental_vehicle_to_payload(resolved_vehicle)) + + draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_RENTAL_DRAFT_TTL_MINUTES) + self.state.set_entry("pending_rental_drafts", user_id, draft) + self._mark_rental_flow_active(user_id=user_id, active_task="rental_create") + + missing = [field for field in RENTAL_REQUIRED_FIELDS if field not in draft_payload] + if missing: + if "rental_vehicle_id" in missing: + fleet_response = await self._try_list_rental_fleet_for_selection( + message=message, + user_id=user_id, + payload=draft_payload, + turn_decision=turn_decision, + force=bool(draft) or explicit_rental_request or rental_listing_request or should_bootstrap_from_context, + ) + if fleet_response: + return fleet_response + rental_results = self._get_last_rental_results(user_id=user_id) + if rental_results: + return self._render_rental_selection_from_fleet_prompt(rental_results) + return self._render_missing_rental_fields_prompt(missing) + + try: + tool_result = await self.tool_executor.execute( + "abrir_locacao_aluguel", + { + "rental_vehicle_id": draft_payload["rental_vehicle_id"], + "placa": draft_payload.get("placa"), + "data_inicio": draft_payload["data_inicio"], + "data_fim_prevista": draft_payload["data_fim_prevista"], + "cpf": draft_payload.get("cpf"), + }, + user_id=user_id, + ) + except HTTPException as exc: + return self._http_exception_detail(exc) + + self._store_last_rental_contract(user_id=user_id, payload=tool_result) + self._reset_pending_rental_states(user_id=user_id) + return self._fallback_format_tool_result("abrir_locacao_aluguel", tool_result) + diff --git a/app/services/flows/rental_flow_support.py b/app/services/flows/rental_flow_support.py new file mode 100644 index 0000000..8411fb7 --- /dev/null +++ b/app/services/flows/rental_flow_support.py @@ -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), + } diff --git a/app/services/flows/review_flow.py b/app/services/flows/review_flow.py index 3caab96..5f876f6 100644 --- a/app/services/flows/review_flow.py +++ b/app/services/flows/review_flow.py @@ -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) + diff --git a/app/services/flows/review_flow_support.py b/app/services/flows/review_flow_support.py new file mode 100644 index 0000000..ed13273 --- /dev/null +++ b/app/services/flows/review_flow_support.py @@ -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 diff --git a/app/services/orchestration/conversation_history_service.py b/app/services/orchestration/conversation_history_service.py index 64c8427..bbce6ca 100644 --- a/app/services/orchestration/conversation_history_service.py +++ b/app/services/orchestration/conversation_history_service.py @@ -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, diff --git a/app/services/orchestration/conversation_policy.py b/app/services/orchestration/conversation_policy.py index 30f95fc..6f23453 100644 --- a/app/services/orchestration/conversation_policy.py +++ b/app/services/orchestration/conversation_policy.py @@ -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: - 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 += 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:]: - 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), + 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) + 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, + 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: - if self.looks_like_fresh_operational_request(message, turn_decision=turn_decision): - context["pending_order_selection"] = None - self._save_context(user_id=user_id, context=context) - return 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) diff --git a/app/services/orchestration/conversation_state_store.py b/app/services/orchestration/conversation_state_store.py index ecc4366..883df31 100644 --- a/app/services/orchestration/conversation_state_store.py +++ b/app/services/orchestration/conversation_state_store.py @@ -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,65 +19,84 @@ 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 - now = utc_now() - context = self.user_contexts.get(user_id) - if context and context["expires_at"] >= now: - context["expires_at"] = now + timedelta(minutes=ttl_minutes) - return - self.user_contexts[user_id] = { - "active_domain": "general", - "active_task": None, - "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, - "expires_at": now + timedelta(minutes=ttl_minutes), - } + with self._lock: + now = utc_now() + context = self.user_contexts.get(user_id) + if context and context["expires_at"] >= now: + context["expires_at"] = now + timedelta(minutes=ttl_minutes) + return + self.user_contexts[user_id] = { + "active_domain": "general", + "active_task": None, + "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, + "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 - context = self.user_contexts.get(user_id) - if not context: - return None - if context["expires_at"] < utc_now(): - self.user_contexts.pop(user_id, None) - return None - return context + with self._lock: + context = self.user_contexts.get(user_id) + if not context: + return None + if context["expires_at"] < utc_now(): + self.user_contexts.pop(user_id, None) + return None + return context 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 - entries = getattr(self, bucket) - entry = entries.get(user_id) - if not entry: - return None - if expire and entry.get("expires_at") and entry["expires_at"] < utc_now(): - entries.pop(user_id, None) - return None - return entry + with self._lock: + entries = getattr(self, bucket) + entry = entries.get(user_id) + if not entry: + return None + if expire and entry.get("expires_at") and entry["expires_at"] < utc_now(): + entries.pop(user_id, None) + return None + return entry def set_entry(self, bucket: str, user_id: int | None, value: dict) -> None: if user_id is None: return - getattr(self, bucket)[user_id] = value + 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 - return getattr(self, bucket).pop(user_id, None) + with self._lock: + return getattr(self, bucket).pop(user_id, None) diff --git a/app/services/orchestration/orchestrator_config.py b/app/services/orchestration/orchestrator_config.py index 02e6673..02cfbbd 100644 --- a/app/services/orchestration/orchestrator_config.py +++ b/app/services/orchestration/orchestrator_config.py @@ -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", } + + + diff --git a/app/services/orchestration/orchestrator_context_manager.py b/app/services/orchestration/orchestrator_context_manager.py new file mode 100644 index 0000000..1c3c292 --- /dev/null +++ b/app/services/orchestration/orchestrator_context_manager.py @@ -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) + diff --git a/app/services/orchestration/orchestrator_execution_manager.py b/app/services/orchestration/orchestrator_execution_manager.py new file mode 100644 index 0000000..097b409 --- /dev/null +++ b/app/services/orchestration/orchestrator_execution_manager.py @@ -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, + ) diff --git a/app/services/orchestration/orquestrador_service.py b/app/services/orchestration/orquestrador_service.py index 26a3ca6..b983f32 100644 --- a/app/services/orchestration/orquestrador_service.py +++ b/app/services/orchestration/orquestrador_service.py @@ -21,8 +21,12 @@ from app.services.orchestration.conversation_state_repository import Conversatio from app.services.orchestration.entity_normalizer import EntityNormalizer from app.services.orchestration.message_planner import MessagePlanner from app.services.orchestration.conversation_history_service import ConversationHistoryService +from app.services.orchestration.sensitive_data import mask_sensitive_payload, mask_sensitive_text from app.services.orchestration.state_repository_factory import get_conversation_state_repository +from app.services.orchestration.orchestrator_context_manager import OrchestratorContextManager +from app.services.orchestration.orchestrator_execution_manager import OrchestratorExecutionManager from app.services.flows.order_flow import OrderFlowMixin +from app.services.flows.rental_flow import RentalFlowMixin from app.services.orchestration.prompt_builders import ( build_force_tool_prompt, build_result_prompt, @@ -31,13 +35,14 @@ from app.services.orchestration.prompt_builders import ( from app.services.flows.review_flow import ReviewFlowMixin from app.services.orchestration.tool_executor import ToolExecutor from app.services.tools.tool_registry import ToolRegistry +from app.services.orchestration.response_formatter import format_currency_br, format_datetime_for_chat logger = logging.getLogger(__name__) # Coordenador principal do turno conversacional: # atualiza estado, pede decisoes ao modelo, continua fluxos e executa tools. -class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): +class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin): def __init__( self, db: Session, @@ -53,6 +58,22 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): self.policy = ConversationPolicy(service=self) self.history_service = ConversationHistoryService() + @property + def _context_manager(self) -> OrchestratorContextManager: + manager = getattr(self, "__context_manager", None) + if manager is None: + manager = OrchestratorContextManager(service=self) + setattr(self, "__context_manager", manager) + return manager + + @property + def _execution_manager(self) -> OrchestratorExecutionManager: + manager = getattr(self, "__execution_manager", None) + if manager is None: + manager = OrchestratorExecutionManager(service=self, logger_instance=logger) + setattr(self, "__execution_manager", manager) + return manager + def _build_orchestration_tool_handlers(self) -> dict: return { "limpar_contexto_conversa": self._tool_limpar_contexto_conversa, @@ -66,12 +87,18 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): turn_started_at = utc_now() turn_started_perf = perf_counter() turn_history_persisted = False - self._turn_trace = { + turn_trace = { "request_id": str(uuid4()), "conversation_id": f"user:{user_id}" if user_id is not None else "anonymous", "user_id": user_id, "started_at": turn_started_at, } + turn_trace_stack = getattr(self, "_turn_trace_stack", None) + if not isinstance(turn_trace_stack, list): + turn_trace_stack = [] + self._turn_trace_stack = turn_trace_stack + turn_trace_stack.append(turn_trace) + self._turn_trace = turn_trace self._log_turn_event("turn_received", message=message) async def finish(response: str, queue_notice: str | None = None) -> str: @@ -85,7 +112,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): base_response=composed, user_id=user_id, ) - self._turn_trace["elapsed_ms"] = round((perf_counter() - turn_started_perf) * 1000, 2) + turn_trace["elapsed_ms"] = round((perf_counter() - turn_started_perf) * 1000, 2) self._log_turn_event("turn_completed", response=final_response) if not turn_history_persisted: self._finalize_turn_history( @@ -130,6 +157,20 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): ) if active_sales_follow_up: return active_sales_follow_up + pending_rental_selection_follow_up = await self._try_handle_pending_rental_selection_follow_up( + message=message, + user_id=user_id, + finish=finish, + ) + if pending_rental_selection_follow_up: + return pending_rental_selection_follow_up + active_rental_follow_up = await self._try_handle_active_rental_follow_up( + message=message, + user_id=user_id, + finish=finish, + ) + if active_rental_follow_up: + return active_rental_follow_up active_review_follow_up = await self._try_handle_active_review_follow_up( message=message, user_id=user_id, @@ -137,6 +178,13 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): ) if active_review_follow_up: return active_review_follow_up + current_rental_info = await self._try_handle_current_rental_info_request( + message=message, + user_id=user_id, + finish=finish, + ) + if current_rental_info: + return current_rental_info # Faz uma leitura inicial do turno para ajudar a policy # com fila, troca de contexto e comandos globais. early_turn_decision = await self._extract_turn_decision_with_llm( @@ -166,6 +214,16 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): if queued_followup: return queued_followup + deterministic_rental_management = await self._try_handle_deterministic_rental_management( + message=message, + user_id=user_id, + queue_notice=None, + finish=finish, + ) + if deterministic_rental_management: + return deterministic_rental_management + + message_plan = await self._extract_message_plan_with_llm( message=message, user_id=user_id, @@ -261,6 +319,11 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): turn_decision=turn_decision, user_id=user_id, ) + should_prioritize_rental_flow = self._should_prioritize_rental_flow( + turn_decision=turn_decision, + user_id=user_id, + message=routing_message, + ) domain_hint = self._domain_from_turn_decision(turn_decision) if domain_hint == "general": domain_hint = self._domain_from_intents(extracted_entities.get("intents", {})) @@ -272,6 +335,8 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): extracted_entities=extracted_entities, ): domain_hint = "sales" + elif self._has_rental_listing_request(routing_message, turn_decision=turn_decision) or self._has_explicit_rental_request(routing_message): + domain_hint = "rental" context_switch_response = self._handle_context_switch( message=routing_message, user_id=user_id, @@ -311,6 +376,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): and not should_prioritize_review_flow and not should_prioritize_review_management and not should_prioritize_order_flow + and not should_prioritize_rental_flow ): return await finish(decision_response, queue_notice=queue_notice) if ( @@ -319,6 +385,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): and not should_prioritize_review_flow and not should_prioritize_review_management and not should_prioritize_order_flow + and not should_prioritize_rental_flow ): return await finish(decision_response, queue_notice=queue_notice) @@ -391,6 +458,15 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): ) if order_response: return await finish(order_response, queue_notice=queue_notice) + rental_response = await self._try_collect_and_open_rental( + message=routing_message, + user_id=user_id, + extracted_fields={}, + intents={}, + turn_decision=turn_decision, + ) + if rental_response: + return await finish(rental_response, queue_notice=queue_notice) tools = self.registry.get_tools() @@ -475,7 +551,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): return await finish(text, queue_notice=queue_notice) except Exception as exc: if not turn_history_persisted: - self._turn_trace["elapsed_ms"] = round((perf_counter() - turn_started_perf) * 1000, 2) + turn_trace["elapsed_ms"] = round((perf_counter() - turn_started_perf) * 1000, 2) self._finalize_turn_history( user_message=message, assistant_response=None, @@ -484,6 +560,17 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): ) turn_history_persisted = True raise + finally: + current_stack = getattr(self, "_turn_trace_stack", None) + if isinstance(current_stack, list): + if current_stack and current_stack[-1] is turn_trace: + current_stack.pop() + else: + try: + current_stack.remove(turn_trace) + except ValueError: + pass + self._turn_trace = current_stack[-1] if current_stack else None async def _try_execute_orchestration_control_tool( self, @@ -559,6 +646,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): and ( self._has_open_flow(user_id=user_id, domain="review") or self._has_open_flow(user_id=user_id, domain="sales") + or self._has_open_flow(user_id=user_id, domain="rental") or bool((self._get_user_context(user_id) or {}).get("pending_switch")) or bool((self._get_user_context(user_id) or {}).get("order_queue")) ) @@ -629,6 +717,660 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): return None return await finish(response) + # Continua a abertura de locacao quando o usuario responde a uma lista pendente. + async def _try_handle_pending_rental_selection_follow_up( + self, + message: str, + user_id: int | None, + finish, + ) -> str | None: + if user_id is None: + return None + pending_selection = self.state.get_entry("pending_rental_selections", user_id, expire=True) + if not pending_selection: + return None + if not self._should_bootstrap_rental_from_context( + message=message, + user_id=user_id, + payload={}, + ): + return None + response = await self._try_collect_and_open_rental( + message=message, + user_id=user_id, + extracted_fields={}, + intents={}, + turn_decision={ + "intent": "rental_create", + "domain": "rental", + "action": "collect_rental_create", + }, + ) + if not response: + return None + return await finish(response) + + # Consome respostas curtas enquanto um fluxo de locacao estiver ativo. + async def _try_handle_active_rental_follow_up( + self, + message: str, + user_id: int | None, + finish, + ) -> str | None: + if user_id is None: + return None + context = self._get_user_context(user_id) + if not isinstance(context, dict): + return None + if str(context.get("active_domain") or "").strip().lower() != "rental": + return None + + pending_rental_draft = self.state.get_entry("pending_rental_drafts", user_id, expire=True) + pending_rental_selection = self.state.get_entry("pending_rental_selections", user_id, expire=True) + if not pending_rental_draft and not pending_rental_selection: + return None + + normalized_message = self.normalizer.normalize_text(message).strip() + if self._looks_like_explicit_domain_shift_request(normalized_message): + return None + if ( + self._has_order_listing_request(message) + or self._has_explicit_order_request(message) + or self._has_stock_listing_request(message) + or ( + self._has_rental_return_management_request(message, user_id=user_id) + and not self._looks_like_pending_rental_due_date_follow_up( + message=message, + user_id=user_id, + pending_rental_draft=pending_rental_draft, + context=context, + ) + ) + or self._has_rental_payment_or_fine_request(message) + ): + return None + + response = await self._try_collect_and_open_rental( + message=message, + user_id=user_id, + extracted_fields={}, + intents={}, + turn_decision={ + "intent": "rental_create", + "domain": "rental", + "action": "collect_rental_create", + }, + ) + if response: + return await finish(response) + return None + + # Reconhece quando "devolucao ..." ainda e so a data prevista do draft atual. + def _looks_like_pending_rental_due_date_follow_up( + self, + message: str, + user_id: int | None, + pending_rental_draft, + context: dict | None = None, + ) -> bool: + if user_id is None or not isinstance(pending_rental_draft, dict): + return False + if not isinstance(context, dict): + context = self._get_user_context(user_id) + if not isinstance(context, dict) or context.get("active_task") != "rental_create": + return False + + payload = pending_rental_draft.get("payload") + if not isinstance(payload, dict) or payload.get("data_fim_prevista"): + return False + if not (payload.get("data_inicio") or payload.get("rental_vehicle_id") or payload.get("placa")): + return False + + normalized_message = self._normalize_text(message).strip() + if "devolucao" not in normalized_message: + return False + return bool(self._extract_rental_datetimes_from_text(message)) + + # Limpa valores extraidos do texto e descarta marcadores vazios ou placeholders. + def _clean_extracted_rental_value(self, value: str | None) -> str | None: + text = str(value or "").strip(" \t\r\n.;,") + if not text: + return None + if re.fullmatch(r"<[^>]*>", text): + text = text[1:-1].strip(" \t\r\n.;,") + if not text: + return None + normalized = self._normalize_text(text).strip() + if normalized in { + "n/a", + "na", + "nao informado", + "nao informada", + "nao identificado", + "nao identificada", + "desconhecido", + "desconhecida", + "sem informacao", + "null", + "none", + "...", + }: + return None + return text + + # Extrai valores rotulados do texto no formato campo: valor. + def _extract_rental_labeled_value(self, text: str, labels: tuple[str, ...]) -> str | None: + if not labels: + return None + label_pattern = "|".join(re.escape(label) for label in labels) + match = re.search( + rf"(?:^|[\s;\n])(?:{label_pattern})\s*[:=]?\s*(?P[^;\n]+)", + str(text or ""), + flags=re.IGNORECASE, + ) + if not match: + return None + return self._clean_extracted_rental_value(match.group("value")) + + # Localiza o numero do contrato de locacao em texto livre ou rotulado. + def _extract_rental_contract_number_from_text(self, text: str) -> str | None: + match = re.search(r"\bLOC-[A-Z0-9-]+\b", str(text or ""), flags=re.IGNORECASE) + if match: + return str(match.group(0)).strip().upper() + labeled_value = self._extract_rental_labeled_value(text, ("contrato_numero", "contrato")) + if not labeled_value: + return None + labeled_match = re.search(r"\bLOC-[A-Z0-9-]+\b", labeled_value, flags=re.IGNORECASE) + if labeled_match: + return str(labeled_match.group(0)).strip().upper() + return None + + # Tenta descobrir a placa do aluguel a partir da mensagem atual. + def _extract_rental_plate_from_text(self, text: str) -> str | None: + labeled_value = self._extract_rental_labeled_value(text, ("placa",)) + if labeled_value: + labeled_plate = self._normalize_plate(labeled_value) + if labeled_plate: + return labeled_plate + + extracted: dict = {} + self._try_capture_rental_fields_from_message(message=text, payload=extracted) + return self._normalize_plate(extracted.get("placa")) + + # Complementa argumentos com contrato ou placa lembrados no contexto recente. + def _merge_last_rental_reference(self, user_id: int | None, arguments: dict) -> dict: + if not isinstance(arguments, dict): + return {} + last_contract = self._get_last_rental_contract(user_id) + if not isinstance(last_contract, dict): + return arguments + if not arguments.get("contrato_numero") and last_contract.get("contrato_numero"): + arguments["contrato_numero"] = str(last_contract["contrato_numero"]) + if not arguments.get("placa") and last_contract.get("placa"): + arguments["placa"] = str(last_contract["placa"]) + return arguments + + def _has_current_rental_info_request(self, message: str, user_id: int | None = None) -> bool: + if user_id is None: + return False + last_contract = self._get_last_rental_contract(user_id) + if not isinstance(last_contract, dict): + return False + has_policy = hasattr(self, "policy") and getattr(self, "policy") is not None + if has_policy and (self._has_open_flow(user_id, "sales") or self._has_open_flow(user_id, "review")): + return False + + normalized_message = self._normalize_text(message).strip() + if not normalized_message: + return False + if ( + self._has_rental_return_management_request(message, user_id=user_id) + or self._has_rental_payment_request(message, user_id=user_id) + or self._has_rental_fine_request(message, user_id=user_id) + ): + return False + if ( + self._has_explicit_order_request(message) + or self._has_stock_listing_request(message) + or self._has_order_listing_request(message) + or self._has_trade_in_evaluation_request(message) + or self._has_rental_listing_request(message) + or self._has_explicit_rental_request(message) + ): + return False + + question_terms = ( + "qual", + "quais", + "quando", + "quanto", + "status", + "dados", + "informacoes", + "me diga", + "me informa", + "me lembra", + ) + has_question_shape = normalized_message.endswith("?") or any( + term in normalized_message for term in question_terms + ) + if not has_question_shape: + return False + + rental_anchor_terms = ( + "aluguel", + "locacao", + "contrato", + "devolucao", + "diaria", + "pagamento", + "comprovante", + ) + if any(term in normalized_message for term in rental_anchor_terms): + return True + + active_domain = str(((self._get_user_context(user_id) or {}).get("active_domain") or "")).strip().lower() + if active_domain != "rental": + return False + + contextual_detail_terms = ( + "placa", + "veiculo", + "carro", + "modelo", + "inicio", + "valor", + ) + return any(term in normalized_message for term in contextual_detail_terms) + + def _build_current_rental_info_response(self, message: str, user_id: int | None) -> str | None: + snapshot = self._get_last_rental_contract(user_id) + if not isinstance(snapshot, dict): + return None + + normalized_message = self._normalize_text(message).strip() + contract_number = str(snapshot.get("contrato_numero") or "").strip() + plate = str(snapshot.get("placa") or "").strip() + vehicle_model = str(snapshot.get("modelo_veiculo") or "").strip() + start_at = format_datetime_for_chat(snapshot.get("data_inicio")) if snapshot.get("data_inicio") else "" + due_at = format_datetime_for_chat(snapshot.get("data_fim_prevista")) if snapshot.get("data_fim_prevista") else "" + returned_at = format_datetime_for_chat(snapshot.get("data_devolucao")) if snapshot.get("data_devolucao") else "" + daily_rate = format_currency_br(snapshot.get("valor_diaria")) if snapshot.get("valor_diaria") is not None else "" + expected_total = format_currency_br(snapshot.get("valor_previsto")) if snapshot.get("valor_previsto") is not None else "" + final_total = format_currency_br(snapshot.get("valor_final")) if snapshot.get("valor_final") is not None else "" + payment_status = str(snapshot.get("status_pagamento") or "").strip() + payment_at = format_datetime_for_chat(snapshot.get("data_pagamento")) if snapshot.get("data_pagamento") else "" + payment_amount = format_currency_br(snapshot.get("valor_pagamento")) if snapshot.get("valor_pagamento") is not None else "" + + if ( + "devolucao" in normalized_message + or "devolver" in normalized_message + or "data de devolucao" in normalized_message + or "data da devolucao" in normalized_message + ): + if returned_at: + lines = [f"A devolucao do seu aluguel foi registrada em {returned_at}."] + elif due_at: + lines = [f"A devolucao prevista do seu aluguel e {due_at}."] + else: + lines = ["Nao encontrei a data de devolucao prevista do seu aluguel atual."] + if contract_number: + lines.append(f"Contrato: {contract_number}") + if vehicle_model: + lines.append(f"Veiculo: {vehicle_model}") + if plate: + lines.append(f"Placa: {plate}") + return "\n".join(lines) + + if "pagamento" in normalized_message or "comprovante" in normalized_message or "paguei" in normalized_message or "quitado" in normalized_message or "pago" in normalized_message: + if payment_status == "registrado" and payment_at and payment_amount: + lines = [f"O pagamento mais recente do seu aluguel ja foi registrado em {payment_at}, no valor de {payment_amount}."] + elif payment_status == "registrado" and payment_at: + lines = [f"O pagamento mais recente do seu aluguel ja foi registrado em {payment_at}."] + elif payment_status: + lines = [f"O status do pagamento do seu aluguel atual e: {payment_status}."] + else: + lines = ["O pagamento do seu aluguel atual esta em aberto."] + if contract_number: + lines.append(f"Contrato: {contract_number}") + if plate: + lines.append(f"Placa: {plate}") + return "\n".join(lines) + + if "diaria" in normalized_message: + if not daily_rate: + return "Nao encontrei a diaria do seu aluguel atual no contexto recente." + lines = [f"A diaria atual do seu aluguel e {daily_rate}."] + if contract_number: + lines.append(f"Contrato: {contract_number}") + if vehicle_model: + lines.append(f"Veiculo: {vehicle_model}") + return "\n".join(lines) + + if "valor" in normalized_message or "quanto" in normalized_message: + if final_total: + lines = [f"O valor final do seu aluguel esta em {final_total}."] + elif expected_total: + lines = [f"O valor previsto do seu aluguel esta em {expected_total}."] + else: + lines = ["Nao encontrei o valor do seu aluguel atual no contexto recente."] + if contract_number: + lines.append(f"Contrato: {contract_number}") + return "\n".join(lines) + + if "placa" in normalized_message: + if not plate: + return "Nao encontrei a placa do seu aluguel atual no contexto recente." + return f"A placa do seu aluguel atual e {plate}." + + if "contrato" in normalized_message: + if not contract_number: + return "Nao encontrei o numero do contrato do seu aluguel atual no contexto recente." + return f"O numero do contrato do seu aluguel atual e {contract_number}." + + if "inicio" in normalized_message or "retirada" in normalized_message: + if not start_at: + return "Nao encontrei a data de inicio do seu aluguel atual no contexto recente." + lines = [f"O inicio do seu aluguel foi em {start_at}."] + if contract_number: + lines.append(f"Contrato: {contract_number}") + return "\n".join(lines) + + lines = ["Resumo do seu aluguel atual:"] + if contract_number: + lines.append(f"Contrato: {contract_number}") + if vehicle_model: + lines.append(f"Veiculo: {vehicle_model}") + if plate: + lines.append(f"Placa: {plate}") + if start_at: + lines.append(f"Inicio: {start_at}") + if returned_at: + lines.append(f"Devolucao registrada: {returned_at}") + elif due_at: + lines.append(f"Devolucao prevista: {due_at}") + if daily_rate: + lines.append(f"Diaria: {daily_rate}") + if final_total: + lines.append(f"Valor final: {final_total}") + elif expected_total: + lines.append(f"Valor previsto: {expected_total}") + if payment_status == "registrado" and payment_at: + payment_line = f"Pagamento: registrado em {payment_at}" + if payment_amount: + payment_line += f" ({payment_amount})" + lines.append(payment_line) + elif payment_status: + lines.append(f"Pagamento: {payment_status}") + return "\n".join(lines) + + async def _try_handle_current_rental_info_request( + self, + message: str, + user_id: int | None, + finish, + ) -> str | None: + if user_id is None or not self._has_current_rental_info_request(message, user_id=user_id): + return None + response = self._build_current_rental_info_response(message=message, user_id=user_id) + if not response: + return None + return await finish(response) + + # Evita tratar perguntas sobre devolucao como se fossem um encerramento real. + def _looks_like_rental_return_question(self, message: str) -> bool: + normalized_message = self._normalize_text(message).strip() + if "devolucao" not in normalized_message and "devolver" not in normalized_message: + return False + question_terms = ( + "qual", + "quais", + "quando", + "como", + "posso", + "pode", + "consigo", + "me lembra", + "me informe", + "me diz", + "me diga", + ) + return normalized_message.endswith("?") or any(term in normalized_message for term in question_terms) + + # Detecta pedidos para registrar devolucao de locacao. + def _has_rental_return_management_request(self, message: str, user_id: int | None = None) -> bool: + if not self._has_rental_return_request(message): + return False + normalized_message = self._normalize_text(message).strip() + if self._looks_like_rental_return_question(normalized_message): + return False + + has_reference_in_message = bool( + "aluguel" in normalized_message + or "locacao" in normalized_message + or self._extract_rental_contract_number_from_text(message) + or self._extract_rental_plate_from_text(message) + ) + explicit_action_terms = ( + "devolver", + "registrar devolucao", + "registrar a devolucao", + "encerrar locacao", + "fechar locacao", + "finalizar locacao", + ) + has_explicit_action = any(term in normalized_message for term in explicit_action_terms) + + if has_reference_in_message and ( + has_explicit_action + or ( + "devolucao" in normalized_message + and bool(self._extract_rental_datetimes_from_text(message)) + ) + ): + return True + + return bool(self._get_last_rental_contract(user_id) and has_explicit_action) + + # Detecta pedidos para registrar pagamento de aluguel. + def _has_rental_payment_request(self, message: str, user_id: int | None = None) -> bool: + normalized_message = self._normalize_text(message).strip() + if "multa" in normalized_message: + return False + payment_terms = ("pagamento", "comprovante", "pix", "boleto") + if not any(term in normalized_message for term in payment_terms): + return False + return bool( + "aluguel" in normalized_message + or "locacao" in normalized_message + or self._extract_rental_contract_number_from_text(message) + or self._extract_rental_plate_from_text(message) + ) + + # Detecta pedidos para registrar multa vinculada ao aluguel. + def _has_rental_fine_request(self, message: str, user_id: int | None = None) -> bool: + normalized_message = self._normalize_text(message).strip() + if "multa" not in normalized_message: + return False + return bool( + "aluguel" in normalized_message + or "locacao" in normalized_message + or "auto_infracao" in normalized_message + or self._extract_rental_contract_number_from_text(message) + or self._extract_rental_plate_from_text(message) + ) + + # Decide se a mensagem pode virar uma acao de aluguel sem depender do planner. + def _is_deterministic_rental_management_candidate(self, message: str, user_id: int | None) -> bool: + has_policy = hasattr(self, "policy") and getattr(self, "policy") is not None + if has_policy and user_id is not None and ( + self._has_open_flow(user_id, "sales") or self._has_open_flow(user_id, "review") + ): + return False + return bool( + self._has_rental_return_management_request(message, user_id=user_id) + or self._has_rental_payment_request(message, user_id=user_id) + or self._has_rental_fine_request(message, user_id=user_id) + ) + + # Monta os argumentos da devolucao a partir do texto enviado pelo usuario. + def _build_rental_return_arguments_from_message(self, message: str, user_id: int | None) -> dict: + arguments: dict = {} + contract_number = self._extract_rental_contract_number_from_text(message) + if contract_number: + arguments["contrato_numero"] = contract_number + plate = self._extract_rental_plate_from_text(message) + if plate: + arguments["placa"] = plate + date_text = self._extract_rental_labeled_value(message, ("data_devolucao", "data de devolucao")) + if not date_text: + datetimes = self._extract_rental_datetimes_from_text(message) + if datetimes: + date_text = datetimes[-1] + if date_text: + arguments["data_devolucao"] = date_text + return self._merge_last_rental_reference(user_id=user_id, arguments=arguments) + + # Monta os argumentos do pagamento de aluguel com base no texto extraido. + def _build_rental_payment_arguments_from_message(self, message: str, user_id: int | None) -> dict: + arguments: dict = {} + contract_number = self._extract_rental_contract_number_from_text(message) + if contract_number: + arguments["contrato_numero"] = contract_number + plate = self._extract_rental_plate_from_text(message) + if plate: + arguments["placa"] = plate + + amount_text = self._extract_rental_labeled_value(message, ("valor_pago", "valor")) + amount = self._normalize_positive_number(amount_text) + if amount is not None: + arguments["valor"] = float(amount) + + payment_date = self._extract_rental_labeled_value(message, ("data_pagamento", "data do pagamento")) + if not payment_date: + datetimes = self._extract_rental_datetimes_from_text(message) + if datetimes: + payment_date = datetimes[0] + if payment_date: + arguments["data_pagamento"] = payment_date + + favorecido = self._extract_rental_labeled_value(message, ("favorecido",)) + if favorecido: + arguments["favorecido"] = favorecido + + receipt_id = self._extract_rental_labeled_value( + message, + ("identificador_comprovante", "identificador", "nsu"), + ) + if receipt_id: + arguments["identificador_comprovante"] = receipt_id + + observations = self._extract_rental_labeled_value(message, ("observacoes", "observacao")) + if observations: + arguments["observacoes"] = observations + + return self._merge_last_rental_reference(user_id=user_id, arguments=arguments) + + # Monta os argumentos da multa de aluguel a partir da mensagem recebida. + def _build_rental_fine_arguments_from_message(self, message: str, user_id: int | None) -> dict: + arguments: dict = {} + contract_number = self._extract_rental_contract_number_from_text(message) + if contract_number: + arguments["contrato_numero"] = contract_number + plate = self._extract_rental_plate_from_text(message) + if plate: + arguments["placa"] = plate + + notice_number = self._extract_rental_labeled_value( + message, + ("auto_infracao", "auto de infracao", "auto da infracao"), + ) + if notice_number: + arguments["auto_infracao"] = notice_number + + issuing_agency = self._extract_rental_labeled_value( + message, + ("orgao_emissor", "orgao emissor"), + ) + if issuing_agency: + arguments["orgao_emissor"] = issuing_agency + + amount_text = self._extract_rental_labeled_value(message, ("valor",)) + amount = self._normalize_positive_number(amount_text) + if amount is not None: + arguments["valor"] = float(amount) + + violation_date = self._extract_rental_labeled_value(message, ("data_infracao", "data da infracao")) + due_date = self._extract_rental_labeled_value(message, ("vencimento", "data_vencimento", "data de vencimento")) + datetimes = self._extract_rental_datetimes_from_text(message) + if not violation_date and datetimes: + violation_date = datetimes[0] + if not due_date and len(datetimes) >= 2: + due_date = datetimes[1] + if violation_date: + arguments["data_infracao"] = violation_date + if due_date: + arguments["vencimento"] = due_date + + observations = self._extract_rental_labeled_value(message, ("observacoes", "observacao")) + if observations: + arguments["observacoes"] = observations + + return self._merge_last_rental_reference(user_id=user_id, arguments=arguments) + + # Executa devolucao, pagamento ou multa de aluguel quando os dados ja estiverem claros. + async def _try_handle_deterministic_rental_management( + self, + message: str, + user_id: int | None, + queue_notice: str | None, + finish, + ) -> str | None: + if user_id is None or not self._is_deterministic_rental_management_candidate(message, user_id=user_id): + return None + + if self._has_rental_return_management_request(message, user_id=user_id): + tool_name = "registrar_devolucao_aluguel" + arguments = self._build_rental_return_arguments_from_message(message=message, user_id=user_id) + missing_response = None + elif self._has_rental_fine_request(message, user_id=user_id): + tool_name = "registrar_multa_aluguel" + arguments = self._build_rental_fine_arguments_from_message(message=message, user_id=user_id) + missing_response = None + if "valor" not in arguments: + missing_response = "Para registrar a multa de aluguel, preciso do valor informado no documento." + elif self._has_rental_payment_request(message, user_id=user_id): + tool_name = "registrar_pagamento_aluguel" + arguments = self._build_rental_payment_arguments_from_message(message=message, user_id=user_id) + missing_response = None + if "valor" not in arguments: + missing_response = "Para registrar o pagamento do aluguel, preciso do valor informado no comprovante." + else: + return None + + if missing_response: + return await finish(missing_response, queue_notice=queue_notice) + + try: + tool_result = await self._execute_tool_with_trace( + tool_name, + arguments, + user_id=user_id, + ) + except HTTPException as exc: + return await finish(self._http_exception_detail(exc), queue_notice=queue_notice) + + self._capture_successful_tool_side_effects( + tool_name=tool_name, + arguments=arguments, + tool_result=tool_result, + user_id=user_id, + ) + return await finish( + self._fallback_format_tool_result(tool_name, tool_result), + queue_notice=queue_notice, + ) + async def _try_handle_active_sales_follow_up( self, message: str, @@ -852,231 +1594,85 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): queue_notice=queue_notice, ) + # Limpa drafts e selecoes de locacao quando o fluxo termina ou e abortado. + def _reset_pending_rental_states(self, user_id: int | None) -> None: + self._context_manager.reset_pending_rental_states(user_id=user_id) + def _reset_pending_review_states(self, user_id: int | None) -> None: - if user_id is None: - return - self.state.pop_entry("pending_review_drafts", user_id) - self.state.pop_entry("pending_review_confirmations", user_id) - self.state.pop_entry("pending_review_management_drafts", user_id) - self.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) + self._context_manager.reset_pending_review_states(user_id=user_id) def _reset_pending_order_states(self, user_id: int | None) -> None: - if user_id is None: - return - self.state.pop_entry("pending_order_drafts", user_id) - self.state.pop_entry("pending_cancel_order_drafts", user_id) - self.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) + self._context_manager.reset_pending_order_states(user_id=user_id) 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.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 - self._save_user_context(user_id=user_id, context=context) + self._context_manager.clear_user_conversation_state(user_id=user_id) 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 + return self._context_manager.clear_pending_order_navigation(user_id=user_id) 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._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) - - context["pending_switch"] = None - self._save_user_context(user_id=user_id, context=context) - if had_flow: - return f"Fluxo atual de {self._domain_label(active_domain)} cancelado." - return "Nao havia fluxo em andamento para cancelar." + return self._context_manager.cancel_active_flow(user_id=user_id) 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._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._build_next_order_transition(target_domain) - next_response = await self.handle_message(queued_message, user_id=user_id) - return f"{transition}\n{next_response}" - - next_order = self._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._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._build_next_order_transition(target_domain) - next_response = await self.handle_message(str(next_order.get("message") or ""), user_id=user_id) - return f"{transition}\n{next_response}" + return await self._context_manager.continue_next_order_now(user_id=user_id) 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} + return await self._context_manager.tool_limpar_contexto_conversa( + motivo=motivo, + user_id=user_id, + ) 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} + return await self._context_manager.tool_descartar_pedidos_pendentes( + motivo=motivo, + user_id=user_id, + ) 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} + return await self._context_manager.tool_cancelar_fluxo_atual( + motivo=motivo, + user_id=user_id, + ) async def _tool_continuar_proximo_pedido(self, user_id: int | None = None) -> str: - return await self._continue_next_order_now(user_id=user_id) + return await self._context_manager.tool_continuar_proximo_pedido(user_id=user_id) - # Nessa função é onde eu configuro a memória volátil do sistema + # Nessa funcao eu configuro a memoria volatil do sistema def _upsert_user_context(self, user_id: int | None) -> None: - self.state.upsert_user_context(user_id=user_id, ttl_minutes=USER_CONTEXT_TTL_MINUTES) + self._context_manager.upsert_user_context(user_id=user_id) def _get_user_context(self, user_id: int | None) -> dict | None: - return self.state.get_user_context(user_id) + return self._context_manager.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=user_id, context=context) + self._context_manager.save_user_context(user_id=user_id, context=context) def _extract_generic_memory_fields(self, llm_generic_fields: dict | None = None) -> dict: - extracted: dict = {} - llm_fields = llm_generic_fields or {} - - normalized_plate = self._normalize_plate(llm_fields.get("placa")) - if normalized_plate: - extracted["placa"] = normalized_plate - - normalized_cpf = self._normalize_cpf(llm_fields.get("cpf")) - if normalized_cpf: - extracted["cpf"] = normalized_cpf - - normalized_budget = self._normalize_positive_number(llm_fields.get("orcamento_max")) - if normalized_budget: - extracted["orcamento_max"] = int(round(normalized_budget)) - - normalized_profile = self._normalize_vehicle_profile(llm_fields.get("perfil_veiculo")) - if normalized_profile: - extracted["perfil_veiculo"] = normalized_profile - - return extracted + return self._context_manager.extract_generic_memory_fields( + llm_generic_fields=llm_generic_fields, + ) 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: - # "Memoria generica" e um dict acumulado por usuario. - # Campos novos entram e campos repetidos sobrescrevem valor antigo. - context["generic_memory"].update(fields) - context.setdefault("shared_memory", {}).update(fields) - self._save_user_context(user_id=user_id, context=context) + self._context_manager.capture_generic_memory( + user_id=user_id, + llm_generic_fields=llm_generic_fields, + ) def _capture_tool_result_context( self, @@ -1084,41 +1680,11 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): 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_estoque" or not isinstance(tool_result, list): - self._save_user_context(user_id=user_id, context=context) - return - - sanitized: list[dict] = [] - for item in tool_result[:5]: - 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)), - } - ) - - context["last_stock_results"] = sanitized - self._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) + self._context_manager.capture_tool_result_context( + tool_name=tool_name, + tool_result=tool_result, + user_id=user_id, + ) def _capture_successful_tool_side_effects( self, @@ -1127,10 +1693,9 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): tool_result, user_id: int | None, ) -> None: - if tool_name == "agendar_revisao" and isinstance(arguments, dict): - self._store_last_review_package(user_id=user_id, payload=arguments) - self._capture_tool_result_context( + self._context_manager.capture_successful_tool_side_effects( tool_name=tool_name, + arguments=arguments, tool_result=tool_result, user_id=user_id, ) @@ -1142,72 +1707,15 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): 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._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.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, + return await self._context_manager.maybe_build_stock_suggestion_response( + tool_name=tool_name, + arguments=arguments, + tool_result=tool_result, 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) + return self._context_manager.new_tab_memory(user_id=user_id) def _empty_extraction_payload(self) -> dict: return self.normalizer.empty_extraction_payload() @@ -1353,7 +1861,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): def _domain_from_turn_decision(self, turn_decision: dict | None) -> str: domain = str((turn_decision or {}).get("domain") or "general").strip().lower() - if domain in {"review", "sales", "general"}: + if domain in {"review", "sales", "rental", "general"}: return domain return "general" @@ -1442,6 +1950,9 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): ): return False + if self.state.get_entry("pending_rental_drafts", user_id, expire=True) or self.state.get_entry("pending_rental_selections", user_id, expire=True): + return False + entities = extracted_entities if isinstance(extracted_entities, dict) else {} order_fields = entities.get("order_fields") if not isinstance(order_fields, dict): @@ -1470,9 +1981,45 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): "agendamento", "cancelar revisao", "remarcar revisao", + "alugar", + "aluguel", + "locacao", + "locar", + "devolver", ) return any(term in normalized_message for term in shift_terms) + # Define quando o atendimento deve priorizar a continuidade do fluxo de locacao. + def _should_prioritize_rental_flow( + self, + turn_decision: dict | None, + user_id: int | None = None, + message: str | None = None, + ) -> bool: + has_open_rental_draft = bool( + user_id is not None + and ( + self.state.get_entry("pending_rental_drafts", user_id, expire=True) + or self.state.get_entry("pending_rental_selections", user_id, expire=True) + ) + ) + if has_open_rental_draft: + return True + + active_domain = str(((self._get_user_context(user_id) or {}) if user_id is not None else {}).get("active_domain") or "").strip().lower() + if active_domain == "rental": + return True + + if message and user_id is not None and self._should_bootstrap_rental_from_context(message=message, user_id=user_id, payload={}): + return True + + decision = turn_decision or {} + decision_intent = str(decision.get("intent") or "").strip().lower() + if decision_intent in {"rental_create", "rental_list", "rental_search"}: + return True + + return bool(message and (self._has_rental_listing_request(message, turn_decision=turn_decision) or self._has_explicit_rental_request(message))) + def _should_prioritize_review_management( self, turn_decision: dict | None, @@ -1969,19 +2516,15 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): return self._fallback_format_tool_result("agendar_revisao", tool_result) def _build_router_prompt(self, user_message: str, user_id: int | None) -> str: - conversation_context = self._build_context_summary(user_id=user_id) - return build_router_prompt( + return self._execution_manager.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._build_context_summary(user_id=user_id) - return build_force_tool_prompt( + return self._execution_manager.build_force_tool_prompt( user_message=user_message, user_id=user_id, - conversation_context=conversation_context, ) def _build_result_prompt( @@ -1991,29 +2534,21 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): tool_name: str, tool_result, ) -> str: - conversation_context = self._build_context_summary(user_id=user_id) - return build_result_prompt( + return self._execution_manager.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, "_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 + self._execution_manager.capture_turn_decision_trace(turn_decision=turn_decision) def _capture_tool_invocation_trace(self, tool_name: str, arguments: dict | None) -> None: - trace = getattr(self, "_turn_trace", None) - if not isinstance(trace, dict): - return - trace["tool_name"] = str(tool_name or "").strip() or None - trace["tool_arguments"] = dict(arguments or {}) if isinstance(arguments, dict) else None + self._execution_manager.capture_tool_invocation_trace( + tool_name=tool_name, + arguments=arguments, + ) def _finalize_turn_history( self, @@ -2023,69 +2558,25 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): turn_status: str, error_detail: str | None = None, ) -> None: - history_service = getattr(self, "history_service", None) - if history_service is None: - return - - trace = getattr(self, "_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 ""), + self._execution_manager.finalize_turn_history( + user_message=user_message, 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"), + turn_status=turn_status, 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(detail, ensure_ascii=True, separators=(",", ":"), default=str) - return str(detail) - return f"{type(exc).__name__}: {exc}" + return self._execution_manager.format_turn_error(exc) def _log_turn_event(self, event: str, **payload) -> None: - trace = getattr(self, "_turn_trace", {}) or {} - logger.info( - "turn_event=%s payload=%s", - event, - { - "request_id": trace.get("request_id"), - "conversation_id": trace.get("conversation_id"), - **payload, - }, - ) + self._execution_manager.log_turn_event(event, **payload) async def _call_llm_with_trace(self, operation: str, message: str, tools): - started_at = perf_counter() - try: - result = await self.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 + return await self._execution_manager.call_llm_with_trace( + operation=operation, + message=message, + tools=tools, + ) def _merge_pending_draft_tool_arguments( self, @@ -2093,32 +2584,11 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): 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, "state") or self.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.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 + return self._execution_manager.merge_pending_draft_tool_arguments( + tool_name=tool_name, + arguments=arguments, + user_id=user_id, + ) def _normalize_tool_invocation( self, @@ -2126,48 +2596,18 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): arguments: dict | None, user_id: int | None, ) -> tuple[str, dict]: - normalizer = getattr(self, "normalizer", None) - if normalizer is None: - normalizer = EntityNormalizer() - self.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, + return self._execution_manager.normalize_tool_invocation( + tool_name=tool_name, + arguments=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( + return await self._execution_manager.execute_tool_with_trace( 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.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.tool_executor.coerce_http_error(exc), - ) - raise async def _render_tool_response_with_fallback( self, @@ -2176,46 +2616,137 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): 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 + return await self._execution_manager.render_tool_response_with_fallback( + user_message=user_message, + user_id=user_id, + tool_name=tool_name, + tool_result=tool_result, + ) + + def _should_use_deterministic_response(self, tool_name: str) -> bool: + return self._execution_manager.should_use_deterministic_response(tool_name) + + def _normalize_text(self, text: str) -> str: + return self.normalizer.normalize_text(text) + + def _is_low_value_response(self, text: str) -> bool: + return self._execution_manager.is_low_value_response(text) + + def _is_affirmative_message(self, text: str) -> bool: + normalized = self._normalize_text(text).strip().rstrip(".!?,;:") + return normalized in {"sim", "pode", "ok", "confirmo", "aceito", "fechado", "pode sim", "tenho", "tenho sim"} + def _is_negative_message(self, text: str) -> bool: + normalized = self._normalize_text(text).strip().rstrip(".!?,;:") + return ( + normalized in {"nao", "nao quero", "prefiro outro", "outro horario"} + or normalized.startswith("nao") + ) + + def _extract_time_only(self, text: str) -> str | None: + return self.normalizer.extract_hhmm_from_text(text) + + def _merge_date_with_time(self, base_iso: str, new_time_hhmm: str) -> str | None: 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=[], - ) + base_dt = datetime.fromisoformat((base_iso or "").replace("Z", "+00:00")) + except ValueError: + return None + try: + hour_text, minute_text = new_time_hhmm.split(":") + merged = base_dt.replace(hour=int(hour_text), minute=int(minute_text), second=0, microsecond=0) + return merged.isoformat() except Exception: - self._log_turn_event( - "tool_response_fallback", - tool_name=tool_name, - reason="llm_failure", - ) - return fallback_response + return None - 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", + def _capture_review_confirmation_suggestion( + self, + tool_name: str, + arguments: dict, + exc: HTTPException, + user_id: int | None, + ) -> None: + if tool_name != "agendar_revisao" or user_id is None or exc.status_code != 409: + return + detail = exc.detail if isinstance(exc.detail, dict) else {} + suggested_iso = str(detail.get("suggested_iso") or "").strip() + if not suggested_iso: + return + payload = dict(arguments or {}) + if not payload.get("placa"): + return + payload["data_hora"] = suggested_iso + self.state.set_entry("pending_review_confirmations", user_id, { + "payload": payload, + "expires_at": utc_now() + timedelta(minutes=PENDING_REVIEW_TTL_MINUTES), + }) + + async def _try_confirm_pending_review( + self, + message: str, + user_id: int | None, + extracted_review_fields: dict | None = None, + ) -> str | None: + if user_id is None: + return None + pending = self.state.get_entry("pending_review_confirmations", user_id, expire=True) + if not pending: + return None + + time_only = self._extract_time_only(message) + if self._is_negative_message(message) or time_only: + extracted = self._normalize_review_fields(extracted_review_fields) + new_data_hora = extracted.get("data_hora") + if not new_data_hora and time_only: + new_data_hora = self._merge_date_with_time(pending["payload"].get("data_hora", ""), time_only) + if not new_data_hora: + self.state.pop_entry("pending_review_confirmations", user_id) + return "Sem problema. Me informe a nova data e hora desejada para a revisao." + + payload = dict(pending["payload"]) + payload["data_hora"] = new_data_hora + try: + tool_result = await self.tool_executor.execute( + "agendar_revisao", + payload, + user_id=user_id, + ) + except HTTPException as exc: + self.state.pop_entry("pending_review_confirmations", user_id) + self._capture_review_confirmation_suggestion( + tool_name="agendar_revisao", + arguments=payload, + exc=exc, + user_id=user_id, + ) + return self._http_exception_detail(exc) + + self._reset_pending_review_states(user_id=user_id) + self._store_last_review_package(user_id=user_id, payload=payload) + return self._fallback_format_tool_result("agendar_revisao", tool_result) + + if not self._is_affirmative_message(message): + return None + try: + tool_result = await self.tool_executor.execute( + "agendar_revisao", + pending["payload"], + user_id=user_id, ) - return fallback_response - return text or fallback_response + except HTTPException as exc: + self.state.pop_entry("pending_review_confirmations", user_id) + return self._http_exception_detail(exc) + + self._reset_pending_review_states(user_id=user_id) + self._store_last_review_package(user_id=user_id, payload=pending.get("payload")) + return self._fallback_format_tool_result("agendar_revisao", tool_result) def _http_exception_detail(self, exc: HTTPException) -> str: - return self.tool_executor.http_exception_detail(exc) + return self._execution_manager.http_exception_detail(exc) def _fallback_format_tool_result(self, tool_name: str, tool_result) -> str: - return self.tool_executor.fallback_format_tool_result(tool_name=tool_name, tool_result=tool_result) + return self._execution_manager.fallback_format_tool_result( + tool_name=tool_name, + tool_result=tool_result, + ) + + diff --git a/app/services/orchestration/prompt_builders.py b/app/services/orchestration/prompt_builders.py index 26f7635..2098a94 100644 --- a/app/services/orchestration/prompt_builders.py +++ b/app/services/orchestration/prompt_builders.py @@ -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" diff --git a/app/services/orchestration/redis_state_repository.py b/app/services/orchestration/redis_state_repository.py index 9741d97..c4e1b95 100644 --- a/app/services/orchestration/redis_state_repository.py +++ b/app/services/orchestration/redis_state_repository.py @@ -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, } diff --git a/app/services/orchestration/response_formatter.py b/app/services/orchestration/response_formatter.py index 981e906..201c05d 100644 --- a/app/services/orchestration/response_formatter.py +++ b/app/services/orchestration/response_formatter.py @@ -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", diff --git a/app/services/orchestration/sensitive_data.py b/app/services/orchestration/sensitive_data.py new file mode 100644 index 0000000..b50e30e --- /dev/null +++ b/app/services/orchestration/sensitive_data.py @@ -0,0 +1,144 @@ +import re +from typing import Any + + +_CPF_PATTERN = re.compile(r"(? 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:]}" diff --git a/app/services/tools/handlers.py b/app/services/tools/handlers.py index 4094a0d..e13ce77 100644 --- a/app/services/tools/handlers.py +++ b/app/services/tools/handlers.py @@ -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", ] diff --git a/app/services/tools/tool_registry.py b/app/services/tools/tool_registry.py index 9c516b8..71c7e0c 100644 --- a/app/services/tools/tool_registry.py +++ b/app/services/tools/tool_registry.py @@ -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, } diff --git a/deploy/systemd/orquestrador-bootstrap.service.example b/deploy/systemd/orquestrador-bootstrap.service.example new file mode 100644 index 0000000..3814063 --- /dev/null +++ b/deploy/systemd/orquestrador-bootstrap.service.example @@ -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 diff --git a/deploy/systemd/orquestrador.service.example b/deploy/systemd/orquestrador.service.example index b390ca9..9d3e611 100644 --- a/deploy/systemd/orquestrador.service.example +++ b/deploy/systemd/orquestrador.service.example @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index ef56082..91746ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/scripts/stress_smoke.py b/scripts/stress_smoke.py index 78d70b7..80d526b 100644 --- a/scripts/stress_smoke.py +++ b/scripts/stress_smoke.py @@ -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())) diff --git a/tests/test_context_summary.py b/tests/test_context_summary.py index 5828f0f..b84b7a6 100644 --- a/tests/test_context_summary.py +++ b/tests/test_context_summary.py @@ -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() \ No newline at end of file diff --git a/tests/test_conversation_adjustments.py b/tests/test_conversation_adjustments.py index 8ce8e74..66cb5ba 100644 --- a/tests/test_conversation_adjustments.py +++ b/tests/test_conversation_adjustments.py @@ -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() + + + diff --git a/tests/test_conversation_history_service.py b/tests/test_conversation_history_service.py index 4d20d50..7cf7e8a 100644 --- a/tests/test_conversation_history_service.py +++ b/tests/test_conversation_history_service.py @@ -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:") diff --git a/tests/test_conversation_state_store.py b/tests/test_conversation_state_store.py new file mode 100644 index 0000000..39cf44d --- /dev/null +++ b/tests/test_conversation_state_store.py @@ -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() diff --git a/tests/test_inventory_service.py b/tests/test_inventory_service.py new file mode 100644 index 0000000..08301bb --- /dev/null +++ b/tests/test_inventory_service.py @@ -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)) + diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py index 5e3d765..c997b8e 100644 --- a/tests/test_llm_service.py +++ b/tests/test_llm_service.py @@ -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.", + ) diff --git a/tests/test_rental_seed.py b/tests/test_rental_seed.py new file mode 100644 index 0000000..beefb9a --- /dev/null +++ b/tests/test_rental_seed.py @@ -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() + diff --git a/tests/test_rental_service.py b/tests/test_rental_service.py new file mode 100644 index 0000000..66f7f14 --- /dev/null +++ b/tests/test_rental_service.py @@ -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() diff --git a/tests/test_review_service.py b/tests/test_review_service.py new file mode 100644 index 0000000..e21863f --- /dev/null +++ b/tests/test_review_service.py @@ -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() diff --git a/tests/test_runtime_bootstrap.py b/tests/test_runtime_bootstrap.py index 2d988f8..afec630 100644 --- a/tests/test_runtime_bootstrap.py +++ b/tests/test_runtime_bootstrap.py @@ -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() diff --git a/tests/test_telegram_multimodal.py b/tests/test_telegram_multimodal.py new file mode 100644 index 0000000..07df1e7 --- /dev/null +++ b/tests/test_telegram_multimodal.py @@ -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]) + diff --git a/tests/test_turn_decision_contract.py b/tests/test_turn_decision_contract.py index 5616bea..29c2b52 100644 --- a/tests/test_turn_decision_contract.py +++ b/tests/test_turn_decision_contract.py @@ -1,16 +1,24 @@ import os import unittest from types import SimpleNamespace +from unittest.mock import patch + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool os.environ.setdefault("DEBUG", "false") from datetime import datetime, timedelta from app.core.time_utils import utc_now +from app.db.mock_database import MockBase +from app.db.mock_models import RentalContract, RentalPayment, RentalVehicle from app.services.orchestration.conversation_policy import ConversationPolicy from app.services.orchestration.entity_normalizer import EntityNormalizer from app.services.orchestration.message_planner import MessagePlanner from app.services.orchestration.orquestrador_service import OrquestradorService +from app.services.orchestration.tool_executor import ToolExecutor class FakeLLM: @@ -80,6 +88,16 @@ class FakeToolExecutor: } +class StaticToolRegistry: + def __init__(self, result=None): + self.result = result if result is not None else {"ok": True} + self.calls = [] + + async def execute(self, tool_name: str, arguments: dict, user_id: int | None = None): + self.calls.append((tool_name, arguments, user_id)) + return self.result + + class FakePolicyService: def __init__(self, state): self.state = state @@ -149,7 +167,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): "action": "ask_missing_fields", "entities": { "generic_memory": {}, - "review_fields": {"placa": "abc1234", "data_hora": "10/03/2026 às 09:00"}, + "review_fields": {"placa": "abc1234", "data_hora": "10/03/2026 \u00e0s 09:00"}, "review_management_fields": {}, "order_fields": {}, "cancel_order_fields": {} @@ -166,14 +184,14 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): ) planner = MessagePlanner(llm=llm, normalizer=EntityNormalizer()) - decision = await planner.extract_turn_decision("Quero agendar revisão amanhã às 09:00", user_id=7) + decision = await planner.extract_turn_decision("Quero agendar revis\u00e3o amanh\u00e3 \u00e0s 09:00", user_id=7) self.assertEqual(llm.calls, 2) self.assertEqual(decision["intent"], "review_schedule") self.assertEqual(decision["domain"], "review") self.assertEqual(decision["action"], "ask_missing_fields") self.assertEqual(decision["entities"]["review_fields"]["placa"], "ABC1234") - self.assertEqual(decision["entities"]["review_fields"]["data_hora"], "10/03/2026 às 09:00") + self.assertEqual(decision["entities"]["review_fields"]["data_hora"], "10/03/2026 \u00e0s 09:00") self.assertEqual(decision["missing_fields"], ["modelo", "ano", "km"]) def test_parse_json_object_accepts_python_style_dict_with_trailing_commas(self): @@ -972,6 +990,52 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(response, "Pedido PED-1 atualizado.\nStatus: Cancelado") self.assertEqual(service.llm.calls, 0) + async def test_turn_decision_rental_fleet_listing_uses_deterministic_response_without_result_llm(self): + registry = StaticToolRegistry( + result=[ + {"id": 1, "placa": "RAA1A01", "modelo": "Chevrolet Tracker", "categoria": "suv", "ano": 2024, "valor_diaria": 219.9, "status": "disponivel"}, + {"id": 2, "placa": "RAA1A02", "modelo": "Fiat Pulse", "categoria": "suv", "ano": 2024, "valor_diaria": 189.9, "status": "disponivel"}, + ] + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = FakeState() + service.normalizer = EntityNormalizer() + service.tool_executor = ToolExecutor(registry=registry) + service.llm = FakeLLM([]) + service._capture_review_confirmation_suggestion = lambda **kwargs: None + service._capture_tool_result_context = lambda **kwargs: None + service._capture_tool_invocation_trace = lambda **kwargs: None + service._log_turn_event = lambda *args, **kwargs: None + + async def fake_render_tool_response_with_fallback(**kwargs): + raise AssertionError("nao deveria usar llm para listagem de locacao") + + service._render_tool_response_with_fallback = fake_render_tool_response_with_fallback + service._http_exception_detail = lambda exc: str(exc) + service._is_low_value_response = lambda text: False + + async def finish(response: str, queue_notice: str | None = None) -> str: + return response if not queue_notice else f"{queue_notice}\n{response}" + + response = await service._try_execute_business_tool_from_turn_decision( + message="quais carros estao disponiveis para aluguel", + user_id=7, + turn_decision={ + "action": "call_tool", + "tool_name": "consultar_frota_aluguel", + "tool_arguments": {"valor_diaria_max": 220}, + }, + queue_notice=None, + finish=finish, + ) + + self.assertEqual(registry.calls[0][0], "consultar_frota_aluguel") + self.assertEqual(registry.calls[0][2], 7) + self.assertIn("Encontrei 2 veiculo(s) para locacao:", response) + self.assertIn("RAA1A01", response) + self.assertIn("numero da lista, a placa ou o modelo", response) + self.assertEqual(service.llm.calls, 0) + async def test_confirm_pending_review_clears_open_review_draft_after_suggested_time_success(self): state = FakeState( entries={ @@ -1248,6 +1312,109 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(history_calls[0]["turn_status"], "completed") self.assertEqual(history_calls[0]["intent"], "general") + async def test_handle_message_restores_outer_turn_trace_after_nested_call(self): + state = FakeState( + contexts={ + 1: { + "active_domain": "general", + "generic_memory": {}, + "shared_memory": {}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, + } + } + ) + history_calls = [] + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service.policy = ConversationPolicy(service=service) + service.history_service = SimpleNamespace(record_turn=lambda **kwargs: history_calls.append(kwargs)) + service._empty_extraction_payload = service.normalizer.empty_extraction_payload + service._log_turn_event = lambda *args, **kwargs: None + service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response + service._upsert_user_context = lambda user_id: None + service._get_user_context = lambda user_id: state.get_user_context(user_id) + service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context) + + async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None): + if base_response == "resposta externa": + nested_response = await service.handle_message("mensagem interna", user_id=user_id) + return f"{base_response}\n{nested_response}" + return base_response + + async def fake_extract_turn_decision(message: str, user_id: int | None): + return { + "intent": "general", + "domain": "general", + "action": "answer_user", + "entities": service.normalizer.empty_extraction_payload(), + "missing_fields": [], + "selection_index": None, + "tool_name": None, + "tool_arguments": {}, + "response_to_user": "resposta interna" if message == "mensagem interna" else "resposta externa", + } + + async def fake_extract_message_plan(message: str, user_id: int | None): + return {"orders": [{"domain": "general", "message": message}]} + + service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order + service._extract_turn_decision_with_llm = fake_extract_turn_decision + service._extract_message_plan_with_llm = fake_extract_message_plan + service._prepare_message_for_single_order = lambda message, user_id, routing_plan=None: (message, None, None) + service._resolve_entities_for_message_plan = lambda message_plan, routed_message: service.normalizer.empty_extraction_payload() + + async def fake_try_handle_immediate_context_reset(**kwargs): + return None + + async def fake_try_resolve_pending_order_selection(**kwargs): + return None + + async def fake_try_continue_queued_order(**kwargs): + return None + + async def fake_try_execute_orchestration_control_tool(**kwargs): + return None + + async def fake_try_execute_business_tool_from_turn_decision(**kwargs): + return None + + service._try_handle_immediate_context_reset = fake_try_handle_immediate_context_reset + service._try_resolve_pending_order_selection = fake_try_resolve_pending_order_selection + service._try_continue_queued_order = fake_try_continue_queued_order + service._try_execute_orchestration_control_tool = fake_try_execute_orchestration_control_tool + service._try_execute_business_tool_from_turn_decision = fake_try_execute_business_tool_from_turn_decision + service._handle_context_switch = lambda **kwargs: None + service._update_active_domain = lambda **kwargs: None + + async def fake_extract_entities_with_llm(message: str, user_id: int | None): + return service.normalizer.empty_extraction_payload() + + async def fake_extract_missing_sales_search_context_with_llm(**kwargs): + return {} + + service._extract_entities_with_llm = fake_extract_entities_with_llm + service._extract_missing_sales_search_context_with_llm = fake_extract_missing_sales_search_context_with_llm + service._domain_from_intents = lambda intents: "general" + + response = await service.handle_message("mensagem externa", user_id=1) + + self.assertEqual(response, "resposta externa\nresposta interna") + self.assertEqual(len(history_calls), 2) + self.assertEqual( + {call["user_message"] for call in history_calls}, + {"mensagem externa", "mensagem interna"}, + ) + self.assertEqual( + {call["assistant_response"] for call in history_calls}, + {"resposta externa\nresposta interna", "resposta interna"}, + ) + self.assertEqual(len({call["request_id"] for call in history_calls}), 2) + async def test_handle_message_persists_failed_turn_history(self): state = FakeState( contexts={ @@ -1297,6 +1464,41 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertIn("RuntimeError", history_calls[0]["error_detail"]) self.assertIn("falha controlada no turno", history_calls[0]["error_detail"]) + def test_log_turn_event_masks_sensitive_payload(self): + service = OrquestradorService.__new__(OrquestradorService) + service._turn_trace = { + "request_id": "req-1", + "conversation_id": "user:7", + } + + with patch("app.services.orchestration.orquestrador_service.logger.info") as logger_info: + service._log_turn_event( + "tool_completed", + message="Meu cpf 12345678909 e a placa ABC1D23.", + arguments={ + "cpf": "12345678909", + "placa": "ABC1D23", + "external_id": "987654321", + "identificador_comprovante": "NSU123", + }, + result={ + "placa": "ABC1D23", + "identificador_comprovante": "NSU123", + }, + ) + + self.assertTrue(logger_info.called) + payload = logger_info.call_args.args[2] + payload_text = str(payload) + self.assertNotIn("12345678909", payload_text) + self.assertNotIn("ABC1D23", payload_text) + self.assertNotIn("987654321", payload_text) + self.assertNotIn("NSU123", payload_text) + self.assertIn("***.***.***-09", payload_text) + self.assertIn("ABC***3", payload_text) + self.assertIn("******321", payload_text) + self.assertIn("***123", payload_text) + async def test_handle_message_prioritizes_order_flow_over_model_answer_for_purchase_intent(self): state = FakeState( contexts={ @@ -2156,34 +2358,38 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertIn("CPF do cliente", response) - async def test_handle_message_keeps_sales_flow_when_cpf_follow_up_is_misclassified_as_review(self): + async def test_handle_message_short_circuits_llm_when_pending_rental_selection_matches_list_choice(self): state = FakeState( entries={ - "pending_order_drafts": { + "pending_rental_selections": { 1: { - "payload": {"vehicle_id": 15, "modelo_veiculo": "Volkswagen T-Cross 2022", "valor_veiculo": 73224.0}, + "payload": [ + {"id": 1, "placa": "RAA1A01", "modelo": "Chevrolet Tracker", "categoria": "suv", "ano": 2024, "valor_diaria": 219.9, "status": "disponivel"}, + {"id": 2, "placa": "RAA1A02", "modelo": "Fiat Pulse", "categoria": "suv", "ano": 2024, "valor_diaria": 189.9, "status": "disponivel"}, + ], "expires_at": utc_now() + timedelta(minutes=15), } } }, contexts={ 1: { - "active_domain": "sales", - "generic_memory": {"orcamento_max": 70000, "perfil_veiculo": ["suv"]}, - "shared_memory": {"orcamento_max": 70000, "perfil_veiculo": ["suv"]}, + "active_domain": "rental", + "generic_memory": {}, + "shared_memory": {}, "order_queue": [], "pending_order_selection": None, "pending_switch": None, - "last_stock_results": [ - {"id": 15, "modelo": "Volkswagen T-Cross 2022", "categoria": "suv", "preco": 73224.0, "budget_relaxed": True}, - ], - "selected_vehicle": {"id": 15, "modelo": "Volkswagen T-Cross 2022", "categoria": "suv", "preco": 73224.0, "budget_relaxed": True}, + "last_stock_results": [], + "selected_vehicle": None, + "last_rental_results": [], + "selected_rental_vehicle": None, } } ) service = OrquestradorService.__new__(OrquestradorService) service.state = state service.normalizer = EntityNormalizer() + service.policy = ConversationPolicy(service=service) service._empty_extraction_payload = service.normalizer.empty_extraction_payload service._log_turn_event = lambda *args, **kwargs: None service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response @@ -2197,7 +2403,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): service._upsert_user_context = lambda user_id: None async def fake_extract_turn_decision(message: str, user_id: int | None): - raise AssertionError("nao deveria consultar o LLM para continuar um fluxo de venda aberto") + raise AssertionError("nao deveria consultar o LLM para selecao pendente de locacao") service._extract_turn_decision_with_llm = fake_extract_turn_decision @@ -2216,96 +2422,27 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): service._try_continue_queued_order = fake_try_continue_queued_order - async def fake_extract_message_plan(message: str, user_id: int | None): - return { - "orders": [ - { - "domain": "sales", - "message": message, - "entities": service.normalizer.empty_extraction_payload(), - } - ] - } - - service._extract_message_plan_with_llm = fake_extract_message_plan - service._prepare_message_for_single_order = lambda message, user_id, routing_plan=None: (message, None, None) - service._resolve_entities_for_message_plan = lambda message_plan, routed_message: service.normalizer.empty_extraction_payload() - - async def fake_extract_entities(message: str, user_id: int | None): - return { - "generic_memory": {}, - "review_fields": {}, - "review_management_fields": {}, - "order_fields": {}, - "cancel_order_fields": {}, - "intents": {}, - } - - service._extract_entities_with_llm = fake_extract_entities - - async def fake_extract_missing_sales_search_context_with_llm(**kwargs): - return {} - - service._extract_missing_sales_search_context_with_llm = fake_extract_missing_sales_search_context_with_llm - service._domain_from_intents = lambda intents: "general" - - service._update_active_domain = lambda **kwargs: None - - async def fake_try_execute_orchestration_control_tool(**kwargs): - return None - - service._try_execute_orchestration_control_tool = fake_try_execute_orchestration_control_tool - - async def fake_try_execute_business_tool_from_turn_decision(**kwargs): - return "nao deveria executar tool planejada" - - service._try_execute_business_tool_from_turn_decision = fake_try_execute_business_tool_from_turn_decision - - async def fake_try_handle_review_management(**kwargs): - return None - - service._try_handle_review_management = fake_try_handle_review_management - - async def fake_try_confirm_pending_review(**kwargs): - return None - - service._try_confirm_pending_review = fake_try_confirm_pending_review - - async def fake_try_collect_and_schedule_review(**kwargs): - return None - - service._try_collect_and_schedule_review = fake_try_collect_and_schedule_review - - async def fake_try_collect_and_cancel_order(**kwargs): - return None - - service._try_collect_and_cancel_order = fake_try_collect_and_cancel_order - - async def fake_try_handle_order_listing(**kwargs): - return None - - service._try_handle_order_listing = fake_try_handle_order_listing + async def fake_try_collect_and_open_rental(**kwargs): + return "Para abrir a locacao, preciso dos dados abaixo:\n- a data e hora de inicio da locacao" - async def fake_try_collect_and_create_order(**kwargs): - return "Pedido criado com sucesso." - - service._try_collect_and_create_order = fake_try_collect_and_create_order + service._try_collect_and_open_rental = fake_try_collect_and_open_rental response = await service.handle_message( - "12345678909", + "1", user_id=1, ) - self.assertEqual(response, "Pedido criado com sucesso.") + self.assertIn("inicio da locacao", response) - async def test_handle_message_short_circuits_active_review_time_follow_up_before_llm(self): + async def test_handle_message_keeps_rental_create_flow_when_user_informs_due_date_with_devolucao_label(self): state = FakeState( entries={ - "pending_review_drafts": { + "pending_rental_drafts": { 1: { "payload": { - "placa": "ABC1234", - "data_hora_base": "17/03/2026", + "rental_vehicle_id": 3, + "placa": "RAA1A02", + "data_inicio": "19/03/2026 10:00", }, "expires_at": utc_now() + timedelta(minutes=15), } @@ -2313,20 +2450,28 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): }, contexts={ 1: { - "active_domain": "review", - "generic_memory": {"placa": "ABC1234"}, - "shared_memory": {"placa": "ABC1234"}, + "active_domain": "rental", + "active_task": "rental_create", + "generic_memory": {}, + "shared_memory": {}, "order_queue": [], "pending_order_selection": None, "pending_switch": None, "last_stock_results": [], "selected_vehicle": None, + "last_rental_results": [], + "selected_rental_vehicle": {"id": 3, "placa": "RAA1A02", "modelo": "Fiat Pulse"}, + "last_rental_contract": { + "contrato_numero": "LOC-20260319-33CD6567", + "placa": "RAA1A02", + }, } } ) service = OrquestradorService.__new__(OrquestradorService) service.state = state service.normalizer = EntityNormalizer() + service.policy = ConversationPolicy(service=service) service._empty_extraction_payload = service.normalizer.empty_extraction_payload service._log_turn_event = lambda *args, **kwargs: None service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response @@ -2339,28 +2484,888 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order service._upsert_user_context = lambda user_id: None - async def fake_try_handle_pending_stock_selection_follow_up(**kwargs): - return None - - service._try_handle_pending_stock_selection_follow_up = fake_try_handle_pending_stock_selection_follow_up - async def fake_extract_turn_decision(message: str, user_id: int | None): - raise AssertionError("nao deveria consultar o LLM para um follow-up temporal de revisao com draft aberto") + raise AssertionError("nao deveria consultar o LLM durante follow-up ativo de locacao") service._extract_turn_decision_with_llm = fake_extract_turn_decision - async def fake_try_collect_and_schedule_review(**kwargs): - self.assertEqual(kwargs["turn_decision"]["intent"], "review_schedule") - return "Para agendar sua revisao, preciso dos dados abaixo:\n- o modelo do veiculo" + async def fake_try_handle_immediate_context_reset(**kwargs): + return None - service._try_collect_and_schedule_review = fake_try_collect_and_schedule_review + service._try_handle_immediate_context_reset = fake_try_handle_immediate_context_reset - async def fake_try_handle_review_management(**kwargs): + async def fake_try_resolve_pending_order_selection(**kwargs): return None - service._try_handle_review_management = fake_try_handle_review_management + service._try_resolve_pending_order_selection = fake_try_resolve_pending_order_selection - async def fake_try_confirm_pending_review(**kwargs): + async def fake_try_continue_queued_order(**kwargs): + return None + + service._try_continue_queued_order = fake_try_continue_queued_order + + async def fake_try_collect_and_open_rental(**kwargs): + self.assertEqual(kwargs["message"], "devolucao 21/03/2026 10:00") + return "locacao aberta" + + service._try_collect_and_open_rental = fake_try_collect_and_open_rental + + response = await service.handle_message( + "devolucao 21/03/2026 10:00", + user_id=1, + ) + + self.assertEqual(response, "locacao aberta") + + async def test_handle_message_short_circuits_for_rental_return_using_last_contract(self): + state = FakeState( + contexts={ + 1: { + "active_domain": "general", + "generic_memory": {}, + "shared_memory": {}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, + "last_rental_results": [], + "selected_rental_vehicle": None, + "last_rental_contract": { + "contrato_numero": "LOC-20260318-FE69BCF0", + "placa": "RAA1A12", + }, + } + } + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service.policy = ConversationPolicy(service=service) + service.tool_executor = FakeToolExecutor( + result={ + "contrato_numero": "LOC-20260318-FE69BCF0", + "placa": "RAA1A12", + "modelo_veiculo": "Peugeot 208", + "data_devolucao": "2026-03-18T15:46:00", + "valor_final": 449.7, + } + ) + service._empty_extraction_payload = service.normalizer.empty_extraction_payload + service._log_turn_event = lambda *args, **kwargs: None + service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response + service._fallback_format_tool_result = lambda tool_name, tool_result: "devolucao ok" + service._get_user_context = lambda user_id: state.get_user_context(user_id) + service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context) + + async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None): + return base_response + + service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order + service._upsert_user_context = lambda user_id: None + + async def fake_extract_turn_decision(message: str, user_id: int | None): + return { + "intent": "general", + "domain": "general", + "action": "answer_user", + "entities": service.normalizer.empty_extraction_payload(), + "missing_fields": [], + "selection_index": None, + "tool_name": None, + "tool_arguments": {}, + "response_to_user": None, + } + + service._extract_turn_decision_with_llm = fake_extract_turn_decision + + async def fake_try_handle_immediate_context_reset(**kwargs): + return None + + service._try_handle_immediate_context_reset = fake_try_handle_immediate_context_reset + + async def fake_try_resolve_pending_order_selection(**kwargs): + return None + + service._try_resolve_pending_order_selection = fake_try_resolve_pending_order_selection + + async def fake_try_continue_queued_order(**kwargs): + return None + + service._try_continue_queued_order = fake_try_continue_queued_order + + async def fake_extract_message_plan(message: str, user_id: int | None): + raise AssertionError("nao deveria consultar o planner para devolucao deterministica de aluguel") + + service._extract_message_plan_with_llm = fake_extract_message_plan + + response = await service.handle_message( + "devolver a placa RAA1A12", + user_id=1, + ) + + self.assertEqual(response, "devolucao ok") + self.assertEqual( + service.tool_executor.calls, + [ + ( + "registrar_devolucao_aluguel", + {"placa": "RAA1A12", "contrato_numero": "LOC-20260318-FE69BCF0"}, + 1, + ) + ], + ) + + async def test_handle_message_short_circuits_for_rental_payment_receipt_text(self): + state = FakeState( + contexts={ + 1: { + "active_domain": "general", + "generic_memory": {}, + "shared_memory": {}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, + "last_rental_results": [], + "selected_rental_vehicle": None, + "last_rental_contract": { + "contrato_numero": "LOC-20260318-FE69BCF0", + "placa": "RAA1A12", + }, + } + } + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service.policy = ConversationPolicy(service=service) + service.tool_executor = FakeToolExecutor( + result={ + "protocolo": "ALP-20260318-ABCD1234", + "contrato_numero": "LOC-20260318-FE69BCF0", + "placa": "RAA1A12", + "valor": 449.7, + "data_pagamento": "2026-03-18T15:47:00", + } + ) + service._empty_extraction_payload = service.normalizer.empty_extraction_payload + service._log_turn_event = lambda *args, **kwargs: None + service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response + service._fallback_format_tool_result = lambda tool_name, tool_result: "pagamento ok" + service._get_user_context = lambda user_id: state.get_user_context(user_id) + service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context) + + async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None): + return base_response + + service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order + service._upsert_user_context = lambda user_id: None + + async def fake_extract_turn_decision(message: str, user_id: int | None): + return { + "intent": "general", + "domain": "general", + "action": "answer_user", + "entities": service.normalizer.empty_extraction_payload(), + "missing_fields": [], + "selection_index": None, + "tool_name": None, + "tool_arguments": {}, + "response_to_user": None, + } + + service._extract_turn_decision_with_llm = fake_extract_turn_decision + + async def fake_try_handle_immediate_context_reset(**kwargs): + return None + + service._try_handle_immediate_context_reset = fake_try_handle_immediate_context_reset + + async def fake_try_resolve_pending_order_selection(**kwargs): + return None + + service._try_resolve_pending_order_selection = fake_try_resolve_pending_order_selection + + async def fake_try_continue_queued_order(**kwargs): + return None + + service._try_continue_queued_order = fake_try_continue_queued_order + + async def fake_extract_message_plan(message: str, user_id: int | None): + raise AssertionError("nao deveria consultar o planner para pagamento deterministico de aluguel") + + service._extract_message_plan_with_llm = fake_extract_message_plan + + response = await service.handle_message( + "Registrar pagamento de aluguel: valor 449,70; data_pagamento 18/03/2026 15:47; favorecido Locadora XPTO; identificador_comprovante NSU123.", + user_id=1, + ) + + self.assertEqual(response, "pagamento ok") + self.assertEqual( + service.tool_executor.calls, + [ + ( + "registrar_pagamento_aluguel", + { + "valor": 449.7, + "data_pagamento": "18/03/2026 15:47", + "favorecido": "Locadora XPTO", + "identificador_comprovante": "NSU123", + "contrato_numero": "LOC-20260318-FE69BCF0", + "placa": "RAA1A12", + }, + 1, + ) + ], + ) + + async def test_handle_message_short_circuits_for_rental_payment_receipt_text_with_angle_brackets(self): + state = FakeState( + contexts={ + 1: { + "active_domain": "general", + "generic_memory": {}, + "shared_memory": {}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, + "last_rental_results": [], + "selected_rental_vehicle": None, + "last_rental_contract": { + "contrato_numero": "LOC-20260318-4B85490F", + "placa": "RAA1A22", + }, + } + } + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service.policy = ConversationPolicy(service=service) + service.tool_executor = FakeToolExecutor( + result={ + "protocolo": "ALP-20260318-ABCD1234", + "contrato_numero": "LOC-20260318-4B85490F", + "placa": "RAA1A22", + "valor": 479.7, + "data_pagamento": "2026-03-18T16:10:00", + } + ) + service._empty_extraction_payload = service.normalizer.empty_extraction_payload + service._log_turn_event = lambda *args, **kwargs: None + service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response + service._fallback_format_tool_result = lambda tool_name, tool_result: "pagamento ok" + service._get_user_context = lambda user_id: state.get_user_context(user_id) + service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context) + + async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None): + return base_response + + service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order + service._upsert_user_context = lambda user_id: None + + async def fake_extract_turn_decision(message: str, user_id: int | None): + return { + "intent": "general", + "domain": "general", + "action": "answer_user", + "entities": service.normalizer.empty_extraction_payload(), + "missing_fields": [], + "selection_index": None, + "tool_name": None, + "tool_arguments": {}, + "response_to_user": None, + } + + service._extract_turn_decision_with_llm = fake_extract_turn_decision + + async def fake_try_handle_immediate_context_reset(**kwargs): + return None + + service._try_handle_immediate_context_reset = fake_try_handle_immediate_context_reset + + async def fake_try_resolve_pending_order_selection(**kwargs): + return None + + service._try_resolve_pending_order_selection = fake_try_resolve_pending_order_selection + + async def fake_try_continue_queued_order(**kwargs): + return None + + service._try_continue_queued_order = fake_try_continue_queued_order + + async def fake_extract_message_plan(message: str, user_id: int | None): + raise AssertionError("nao deveria consultar o planner para pagamento deterministico de aluguel") + + service._extract_message_plan_with_llm = fake_extract_message_plan + + response = await service.handle_message( + "[imagem recebida no telegram]\nDados extraidos da imagem: Registrar pagamento de aluguel: contrato ; placa ; valor ; data_pagamento <18/03/2026 16:10>; favorecido ; identificador_comprovante ; observacoes .", + user_id=1, + ) + + self.assertEqual(response, "pagamento ok") + self.assertEqual( + service.tool_executor.calls, + [ + ( + "registrar_pagamento_aluguel", + { + "contrato_numero": "LOC-20260318-4B85490F", + "valor": 479.7, + "data_pagamento": "18/03/2026 16:10", + "favorecido": "Locadora XPTO", + "identificador_comprovante": "NSU123456", + "observacoes": "pagamento da locacao", + "placa": "RAA1A22", + }, + 1, + ) + ], + ) + + + async def test_handle_message_short_circuits_for_current_rental_info_question(self): + state = FakeState( + contexts={ + 1: { + "active_domain": "general", + "generic_memory": {}, + "shared_memory": {}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, + "last_rental_results": [], + "selected_rental_vehicle": None, + "last_rental_contract": { + "contrato_numero": "LOC-20260323-CAEECA1C", + "placa": "RAA1A02", + "modelo_veiculo": "Fiat Pulse", + "data_inicio": "2026-03-19T10:00:00", + "data_fim_prevista": "2026-03-21T10:00:00", + "valor_diaria": 189.9, + "valor_previsto": 379.8, + "status": "ativa", + "status_pagamento": "registrado", + "data_pagamento": "2026-03-23T15:47:00", + "valor_pagamento": 379.8, + }, + } + } + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service.policy = ConversationPolicy(service=service) + service._empty_extraction_payload = service.normalizer.empty_extraction_payload + service._log_turn_event = lambda *args, **kwargs: None + service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response + service._get_user_context = lambda user_id: state.get_user_context(user_id) + service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context) + + async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None): + return base_response + + service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order + service._upsert_user_context = lambda user_id: None + + async def fake_try_handle_pending_stock_selection_follow_up(**kwargs): + return None + + async def fake_try_handle_active_sales_follow_up(**kwargs): + return None + + async def fake_try_handle_pending_rental_selection_follow_up(**kwargs): + return None + + async def fake_try_handle_active_rental_follow_up(**kwargs): + return None + + async def fake_try_handle_active_review_follow_up(**kwargs): + return None + + service._try_handle_pending_stock_selection_follow_up = fake_try_handle_pending_stock_selection_follow_up + service._try_handle_active_sales_follow_up = fake_try_handle_active_sales_follow_up + service._try_handle_pending_rental_selection_follow_up = fake_try_handle_pending_rental_selection_follow_up + service._try_handle_active_rental_follow_up = fake_try_handle_active_rental_follow_up + service._try_handle_active_review_follow_up = fake_try_handle_active_review_follow_up + + async def fake_extract_turn_decision(message: str, user_id: int | None): + raise AssertionError("nao deveria consultar o LLM para consulta informativa do aluguel atual") + + service._extract_turn_decision_with_llm = fake_extract_turn_decision + + response = await service.handle_message( + "qual a data de devolucao do meu aluguel?", + user_id=1, + ) + + self.assertIn("A devolucao prevista do seu aluguel e 21/03/2026 10:00.", response) + self.assertIn("Contrato: LOC-20260323-CAEECA1C", response) + self.assertIn("Veiculo: Fiat Pulse", response) + + async def test_handle_message_rehydrates_current_rental_info_from_db_after_restart(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) + + db = SessionLocal() + try: + vehicle = RentalVehicle( + placa="RAA1A02", + modelo="Fiat Pulse", + categoria="suv", + ano=2024, + valor_diaria=189.9, + status="disponivel", + ) + db.add(vehicle) + db.commit() + db.refresh(vehicle) + + contract = RentalContract( + contrato_numero="LOC-20260323-CAEECA1C", + user_id=1, + rental_vehicle_id=vehicle.id, + placa=vehicle.placa, + modelo_veiculo=vehicle.modelo, + categoria=vehicle.categoria, + data_inicio=datetime(2026, 3, 19, 10, 0), + data_fim_prevista=datetime(2026, 3, 21, 10, 0), + data_devolucao=None, + valor_diaria=189.9, + valor_previsto=379.8, + valor_final=None, + status="ativa", + ) + db.add(contract) + db.commit() + db.refresh(contract) + + payment = RentalPayment( + protocolo="ALP-20260323-0B41DD0D", + user_id=1, + rental_contract_id=contract.id, + contrato_numero=contract.contrato_numero, + placa=contract.placa, + valor=379.8, + data_pagamento=datetime(2026, 3, 23, 15, 47), + favorecido="Locadora XPTO", + identificador_comprovante="NSU123456", + observacoes="pagamento da locacao", + ) + db.add(payment) + db.commit() + finally: + db.close() + + state = FakeState( + contexts={ + 1: { + "active_domain": "general", + "generic_memory": {}, + "shared_memory": {}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, + "last_rental_results": [], + "selected_rental_vehicle": None, + } + } + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service.policy = ConversationPolicy(service=service) + service._empty_extraction_payload = service.normalizer.empty_extraction_payload + service._log_turn_event = lambda *args, **kwargs: None + service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response + service._get_user_context = lambda user_id: state.get_user_context(user_id) + service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context) + + async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None): + return base_response + + service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order + service._upsert_user_context = lambda user_id: None + + async def fake_try_handle_pending_stock_selection_follow_up(**kwargs): + return None + + async def fake_try_handle_active_sales_follow_up(**kwargs): + return None + + async def fake_try_handle_pending_rental_selection_follow_up(**kwargs): + return None + + async def fake_try_handle_active_rental_follow_up(**kwargs): + return None + + async def fake_try_handle_active_review_follow_up(**kwargs): + return None + + service._try_handle_pending_stock_selection_follow_up = fake_try_handle_pending_stock_selection_follow_up + service._try_handle_active_sales_follow_up = fake_try_handle_active_sales_follow_up + service._try_handle_pending_rental_selection_follow_up = fake_try_handle_pending_rental_selection_follow_up + service._try_handle_active_rental_follow_up = fake_try_handle_active_rental_follow_up + service._try_handle_active_review_follow_up = fake_try_handle_active_review_follow_up + + async def fake_extract_turn_decision(message: str, user_id: int | None): + raise AssertionError("nao deveria consultar o LLM para consulta informativa do aluguel apos restart") + + service._extract_turn_decision_with_llm = fake_extract_turn_decision + + with patch("app.services.flows.rental_flow_support.SessionMockLocal", SessionLocal): + response = await service.handle_message( + "qual a data de devolucao do meu aluguel?", + user_id=1, + ) + + self.assertIn("A devolucao prevista do seu aluguel e 21/03/2026 10:00.", response) + self.assertIn("Contrato: LOC-20260323-CAEECA1C", response) + self.assertIn("Veiculo: Fiat Pulse", response) + snapshot = state.get_user_context(1)["last_rental_contract"] + self.assertEqual(snapshot["contrato_numero"], "LOC-20260323-CAEECA1C") + self.assertEqual(snapshot["status_pagamento"], "registrado") + self.assertEqual(snapshot["data_fim_prevista"], "2026-03-21T10:00:00") + + def test_store_last_rental_contract_preserves_contract_snapshot_after_payment_update(self): + state = FakeState( + contexts={ + 1: { + "active_domain": "general", + "generic_memory": {}, + "shared_memory": {}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, + "last_rental_results": [], + "selected_rental_vehicle": None, + } + } + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service._get_user_context = lambda user_id: state.get_user_context(user_id) + service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context) + + service._store_last_rental_contract( + user_id=1, + payload={ + "contrato_numero": "LOC-20260323-CAEECA1C", + "placa": "RAA1A02", + "modelo_veiculo": "Fiat Pulse", + "data_inicio": "2026-03-19T10:00:00", + "data_fim_prevista": "2026-03-21T10:00:00", + "valor_diaria": 189.9, + "valor_previsto": 379.8, + "status": "ativa", + }, + ) + service._store_last_rental_contract( + user_id=1, + payload={ + "contrato_numero": "LOC-20260323-CAEECA1C", + "placa": "RAA1A02", + "valor": 379.8, + "data_pagamento": "2026-03-23T15:47:00", + "favorecido": "Locadora XPTO", + "status": "registrado", + }, + ) + + snapshot = state.get_user_context(1)["last_rental_contract"] + self.assertEqual(snapshot["modelo_veiculo"], "Fiat Pulse") + self.assertEqual(snapshot["data_fim_prevista"], "2026-03-21T10:00:00") + self.assertEqual(snapshot["status"], "ativa") + self.assertEqual(snapshot["status_pagamento"], "registrado") + self.assertEqual(snapshot["data_pagamento"], "2026-03-23T15:47:00") + self.assertEqual(snapshot["valor_pagamento"], 379.8) + + def test_has_rental_return_management_request_ignores_return_question_even_with_last_contract(self): + state = FakeState( + contexts={ + 1: { + "active_domain": "general", + "generic_memory": {}, + "shared_memory": {}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, + "last_rental_results": [], + "selected_rental_vehicle": None, + "last_rental_contract": { + "contrato_numero": "LOC-20260318-FE69BCF0", + "placa": "RAA1A12", + }, + } + } + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service._get_user_context = lambda user_id: state.get_user_context(user_id) + + self.assertFalse( + service._has_rental_return_management_request( + "qual a data de devolucao do meu aluguel?", + user_id=1, + ) + ) + + def test_has_rental_payment_request_requires_current_rental_reference(self): + state = FakeState( + contexts={ + 1: { + "active_domain": "general", + "generic_memory": {}, + "shared_memory": {}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, + "last_rental_results": [], + "selected_rental_vehicle": None, + "last_rental_contract": { + "contrato_numero": "LOC-20260318-FE69BCF0", + "placa": "RAA1A12", + }, + } + } + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service._get_user_context = lambda user_id: state.get_user_context(user_id) + + self.assertFalse(service._has_rental_payment_request("segue comprovante pix de R$ 500", user_id=1)) + self.assertTrue(service._has_rental_payment_request("segue comprovante do aluguel de R$ 500", user_id=1)) + + async def test_handle_message_keeps_sales_flow_when_cpf_follow_up_is_misclassified_as_review(self): + state = FakeState( + entries={ + "pending_order_drafts": { + 1: { + "payload": {"vehicle_id": 15, "modelo_veiculo": "Volkswagen T-Cross 2022", "valor_veiculo": 73224.0}, + "expires_at": utc_now() + timedelta(minutes=15), + } + } + }, + contexts={ + 1: { + "active_domain": "sales", + "generic_memory": {"orcamento_max": 70000, "perfil_veiculo": ["suv"]}, + "shared_memory": {"orcamento_max": 70000, "perfil_veiculo": ["suv"]}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [ + {"id": 15, "modelo": "Volkswagen T-Cross 2022", "categoria": "suv", "preco": 73224.0, "budget_relaxed": True}, + ], + "selected_vehicle": {"id": 15, "modelo": "Volkswagen T-Cross 2022", "categoria": "suv", "preco": 73224.0, "budget_relaxed": True}, + } + } + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service._empty_extraction_payload = service.normalizer.empty_extraction_payload + service._log_turn_event = lambda *args, **kwargs: None + service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response + service._get_user_context = lambda user_id: state.get_user_context(user_id) + service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context) + + async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None): + return base_response + + service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order + service._upsert_user_context = lambda user_id: None + + async def fake_extract_turn_decision(message: str, user_id: int | None): + raise AssertionError("nao deveria consultar o LLM para continuar um fluxo de venda aberto") + + service._extract_turn_decision_with_llm = fake_extract_turn_decision + + async def fake_try_handle_immediate_context_reset(**kwargs): + return None + + service._try_handle_immediate_context_reset = fake_try_handle_immediate_context_reset + + async def fake_try_resolve_pending_order_selection(**kwargs): + return None + + service._try_resolve_pending_order_selection = fake_try_resolve_pending_order_selection + + async def fake_try_continue_queued_order(**kwargs): + return None + + service._try_continue_queued_order = fake_try_continue_queued_order + + async def fake_extract_message_plan(message: str, user_id: int | None): + return { + "orders": [ + { + "domain": "sales", + "message": message, + "entities": service.normalizer.empty_extraction_payload(), + } + ] + } + + service._extract_message_plan_with_llm = fake_extract_message_plan + service._prepare_message_for_single_order = lambda message, user_id, routing_plan=None: (message, None, None) + service._resolve_entities_for_message_plan = lambda message_plan, routed_message: service.normalizer.empty_extraction_payload() + + async def fake_extract_entities(message: str, user_id: int | None): + return { + "generic_memory": {}, + "review_fields": {}, + "review_management_fields": {}, + "order_fields": {}, + "cancel_order_fields": {}, + "intents": {}, + } + + service._extract_entities_with_llm = fake_extract_entities + + async def fake_extract_missing_sales_search_context_with_llm(**kwargs): + return {} + + service._extract_missing_sales_search_context_with_llm = fake_extract_missing_sales_search_context_with_llm + service._domain_from_intents = lambda intents: "general" + + service._update_active_domain = lambda **kwargs: None + + async def fake_try_execute_orchestration_control_tool(**kwargs): + return None + + service._try_execute_orchestration_control_tool = fake_try_execute_orchestration_control_tool + + async def fake_try_execute_business_tool_from_turn_decision(**kwargs): + return "nao deveria executar tool planejada" + + service._try_execute_business_tool_from_turn_decision = fake_try_execute_business_tool_from_turn_decision + + async def fake_try_handle_review_management(**kwargs): + return None + + service._try_handle_review_management = fake_try_handle_review_management + + async def fake_try_confirm_pending_review(**kwargs): + return None + + service._try_confirm_pending_review = fake_try_confirm_pending_review + + async def fake_try_collect_and_schedule_review(**kwargs): + return None + + service._try_collect_and_schedule_review = fake_try_collect_and_schedule_review + + async def fake_try_collect_and_cancel_order(**kwargs): + return None + + service._try_collect_and_cancel_order = fake_try_collect_and_cancel_order + + async def fake_try_handle_order_listing(**kwargs): + return None + + service._try_handle_order_listing = fake_try_handle_order_listing + + async def fake_try_collect_and_create_order(**kwargs): + return "Pedido criado com sucesso." + + service._try_collect_and_create_order = fake_try_collect_and_create_order + + response = await service.handle_message( + "12345678909", + user_id=1, + ) + + self.assertEqual(response, "Pedido criado com sucesso.") + + async def test_handle_message_short_circuits_active_review_time_follow_up_before_llm(self): + state = FakeState( + entries={ + "pending_review_drafts": { + 1: { + "payload": { + "placa": "ABC1234", + "data_hora_base": "17/03/2026", + }, + "expires_at": utc_now() + timedelta(minutes=15), + } + } + }, + contexts={ + 1: { + "active_domain": "review", + "generic_memory": {"placa": "ABC1234"}, + "shared_memory": {"placa": "ABC1234"}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, + } + } + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service._empty_extraction_payload = service.normalizer.empty_extraction_payload + service._log_turn_event = lambda *args, **kwargs: None + service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response + service._get_user_context = lambda user_id: state.get_user_context(user_id) + service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context) + + async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None): + return base_response + + service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order + service._upsert_user_context = lambda user_id: None + + async def fake_try_handle_pending_stock_selection_follow_up(**kwargs): + return None + + service._try_handle_pending_stock_selection_follow_up = fake_try_handle_pending_stock_selection_follow_up + + async def fake_extract_turn_decision(message: str, user_id: int | None): + raise AssertionError("nao deveria consultar o LLM para um follow-up temporal de revisao com draft aberto") + + service._extract_turn_decision_with_llm = fake_extract_turn_decision + + async def fake_try_collect_and_schedule_review(**kwargs): + self.assertEqual(kwargs["turn_decision"]["intent"], "review_schedule") + return "Para agendar sua revisao, preciso dos dados abaixo:\n- o modelo do veiculo" + + service._try_collect_and_schedule_review = fake_try_collect_and_schedule_review + + async def fake_try_handle_review_management(**kwargs): + return None + + service._try_handle_review_management = fake_try_handle_review_management + + async def fake_try_confirm_pending_review(**kwargs): return None service._try_confirm_pending_review = fake_try_confirm_pending_review @@ -2627,6 +3632,10 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): "pending_switch": None, "last_stock_results": [{"id": 7, "modelo": "Fiat Argo 2020", "categoria": "suv", "preco": 61857.0}], "selected_vehicle": {"id": 7, "modelo": "Fiat Argo 2020", "categoria": "suv", "preco": 61857.0}, + "last_rental_contract": { + "contrato_numero": "LOC-20260319-33CD6567", + "placa": "RAA1A02", + }, } } ) @@ -2655,6 +3664,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(response, "Contexto da conversa limpo. Podemos recomecar do zero.") self.assertEqual(state.get_user_context(1)["active_domain"], "general") self.assertEqual(state.get_user_context(1)["generic_memory"], {}) + self.assertIsNone(state.get_user_context(1).get("last_rental_contract")) async def test_active_sales_follow_up_ignores_order_listing_request_with_open_order_draft(self): state = FakeState( @@ -3083,6 +4093,73 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertIn("Vou comecar por: Venda: fazer pedido", response) + async def test_pending_order_selection_promotes_new_operational_request_before_previous_options(self): + state = FakeState( + contexts={ + 9: { + "pending_order_selection": { + "orders": [ + {"domain": "sales", "message": "compra", "seed_message": "quero comprar um veiculo", "memory_seed": {}}, + {"domain": "review", "message": "revisao", "seed_message": "quero agendar revisao", "memory_seed": {}}, + {"domain": "rental", "message": "aluguel", "seed_message": "quero alugar um carro", "memory_seed": {}}, + ], + "expires_at": utc_now() + timedelta(minutes=15), + }, + "order_queue": [], + "active_domain": "general", + "generic_memory": {}, + } + } + ) + service = FakePolicyService(state) + policy = ConversationPolicy(service=service) + + response = await policy.try_resolve_pending_order_selection( + message="quais pedidos eu tenho?", + user_id=9, + turn_decision={"domain": "sales", "intent": "order_list", "action": "call_tool", "tool_name": "listar_pedidos"}, + ) + + self.assertIsNone(response) + context = state.get_user_context(9) + self.assertIsNone(context["pending_order_selection"]) + self.assertEqual([item["domain"] for item in context["order_queue"]], ["sales", "review", "rental"]) + self.assertEqual(context["order_queue"][0]["message"], "quero comprar um veiculo") + + async def test_pending_order_selection_skips_duplicate_base_task_when_new_request_is_more_specific(self): + state = FakeState( + contexts={ + 9: { + "pending_order_selection": { + "orders": [ + {"domain": "sales", "message": "compra", "seed_message": "quero comprar um veiculo", "memory_seed": {}}, + {"domain": "review", "message": "revisao", "seed_message": "quero agendar revisao", "memory_seed": {}}, + {"domain": "rental", "message": "aluguel", "seed_message": "quero alugar um carro", "memory_seed": {}}, + ], + "expires_at": utc_now() + timedelta(minutes=15), + }, + "order_queue": [], + "active_domain": "general", + "generic_memory": {}, + } + } + ) + service = FakePolicyService(state) + policy = ConversationPolicy(service=service) + + response = await policy.try_resolve_pending_order_selection( + message="quero comprar um suv ate 95 mil", + user_id=9, + turn_decision={"domain": "sales", "intent": "order_create", "action": "collect_order_create"}, + ) + + self.assertIn("Perfeito. Vou comecar por: Venda: compra", response) + self.assertIn("handled:quero comprar um suv ate 95 mil", response) + context = state.get_user_context(9) + self.assertIsNone(context["pending_order_selection"]) + self.assertEqual(context["active_domain"], "sales") + self.assertEqual([item["domain"] for item in context["order_queue"]], ["review", "rental"]) + async def test_try_continue_queue_prefers_turn_decision_action(self): state = FakeState( contexts={ @@ -3180,6 +4257,111 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(early_response) self.assertEqual(service._get_user_context(9).get("order_queue"), []) + def test_prepare_message_for_single_order_requests_clarification_for_three_actionable_domains(self): + state = FakeState( + contexts={ + 9: { + "active_domain": "general", + "generic_memory": {}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + } + }, + ) + service = FakePolicyService(state) + policy = ConversationPolicy(service=service) + + routed_message, queue_notice, early_response = policy.prepare_message_for_single_order( + message="oi, pode me ajudar com compra, revisao e aluguel?", + user_id=9, + routing_plan={ + "orders": [ + {"domain": "sales", "message": "compra"}, + {"domain": "review", "message": "revisao"}, + ] + }, + ) + + self.assertEqual(routed_message, "oi, pode me ajudar com compra, revisao e aluguel?") + self.assertIsNone(queue_notice) + self.assertIn("Identifiquei 3 acoes", early_response) + self.assertIn("3. Locacao: aluguel", early_response) + pending = state.get_user_context(9)["pending_order_selection"] + self.assertEqual(len(pending["orders"]), 3) + + def test_prepare_message_for_single_order_counts_only_orders_effectively_queued(self): + state = FakeState( + entries={ + "pending_review_drafts": { + 9: { + "payload": {"placa": "ABC1234"}, + "expires_at": utc_now() + timedelta(minutes=15), + } + } + }, + contexts={ + 9: { + "active_domain": "review", + "generic_memory": {}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + } + }, + ) + service = FakePolicyService(state) + policy = ConversationPolicy(service=service) + + routed_message, queue_notice, early_response = policy.prepare_message_for_single_order( + message="quero continuar a revisao e tambem ver aluguel", + user_id=9, + routing_plan={ + "orders": [ + {"domain": "review", "message": "quero continuar a revisao"}, + {"domain": "general", "message": "oi"}, + {"domain": "rental", "message": "quero ver aluguel"}, + ] + }, + ) + + self.assertEqual(routed_message, "quero continuar a revisao e tambem ver aluguel") + self.assertIn("Anotei mais 1 pedido", early_response) + self.assertEqual(len(state.get_user_context(9)["order_queue"]), 1) + self.assertEqual(state.get_user_context(9)["order_queue"][0]["domain"], "rental") + self.assertEqual(state.get_user_context(9)["order_queue"][0]["message"], "quero ver aluguel") + + async def test_pending_order_selection_uses_canonical_seed_message_for_selected_domain(self): + state = FakeState( + contexts={ + 9: { + "active_domain": "general", + "generic_memory": {}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + } + } + ) + service = FakePolicyService(state) + policy = ConversationPolicy(service=service) + policy.store_pending_order_selection( + user_id=9, + orders=[ + {"domain": "sales", "message": "compra", "entities": service.normalizer.empty_extraction_payload()}, + {"domain": "review", "message": "revisao", "entities": service.normalizer.empty_extraction_payload()}, + {"domain": "rental", "message": "aluguel", "entities": service.normalizer.empty_extraction_payload()}, + ], + ) + + response = await policy.try_resolve_pending_order_selection(message="1", user_id=9) + + self.assertIn("Perfeito. Vou comecar por: Venda: compra", response) + self.assertIn("handled:quero comprar um veiculo", response) + context = state.get_user_context(9) + self.assertEqual(context["active_domain"], "sales") + self.assertEqual([item["domain"] for item in context["order_queue"]], ["review", "rental"]) + async def test_tool_continuar_proximo_pedido_reports_empty_queue(self): state = FakeState( @@ -3228,6 +4410,10 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): "pending_switch": None, "last_stock_results": [{"id": 7, "modelo": "Fiat Argo 2020", "categoria": "suv", "preco": 61857.0}], "selected_vehicle": {"id": 7, "modelo": "Fiat Argo 2020", "categoria": "suv", "preco": 61857.0}, + "last_rental_contract": { + "contrato_numero": "LOC-20260319-33CD6567", + "placa": "RAA1A02", + }, } } ) @@ -3507,3 +4693,5 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): if __name__ == "__main__": unittest.main() + +