From 63040d472ce36ae6bf9217e93a5fefbea61df3af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vitor=20Hugo=20Belorio=20Sim=C3=A3o?= Date: Tue, 3 Mar 2026 15:08:29 -0300 Subject: [PATCH] :sparkles: feat: aprimora fluxo de agendamento de revisao com memoria de contexto, validacao de conflitos e sugestao inteligente de horarios - adiciona coleta incremental de dados (slot filling) por usuario para placa, data/hora, modelo, ano, km e historico de revisao, evitando perda de contexto entre mensagens - implementa extracao auxiliar por regex para interpretar respostas curtas e completar o payload de revisao sem depender totalmente do modelo - passa a bloquear conflito global de agenda no mesmo horario (independente de usuario/veiculo) e sugerir proximo horario disponivel - adiciona confirmacao de sugestao pendente (ex.: "pode") e remarca??o quando o cliente recusa o horario sugerido (incluindo casos com apenas novo horario) - amplia a tool de agendar revisao com novos campos obrigatorios e calculo de valor estimado da revisao com base em modelo, ano, km e fidelidade de revisoes anteriores - atualiza schemas e rota mock para refletir o novo contrato de agendamento - endurece o satelite do Telegram com prevencao de instancia duplicada e descarte de backlog no startup para reduzir respostas repetidas --- app/api/routes/mock.py | 4 + app/api/schemas.py | 10 + app/db/tool_seed.py | 62 +++- .../telegram_satellite_service.py | 80 ++++- app/services/handlers.py | 268 +++++++++++++++- app/services/orquestrador_service.py | 296 +++++++++++++++++- app/services/tool_registry.py | 2 + 7 files changed, 707 insertions(+), 15 deletions(-) diff --git a/app/api/routes/mock.py b/app/api/routes/mock.py index ada6a22..675b240 100644 --- a/app/api/routes/mock.py +++ b/app/api/routes/mock.py @@ -75,6 +75,10 @@ async def agendar_revisao_endpoint( return await agendar_revisao( placa=body.placa, data_hora=body.data_hora, + modelo=body.modelo, + ano=body.ano, + km=body.km, + revisao_previa_concessionaria=body.revisao_previa_concessionaria, user_id=body.user_id, ) except SQLAlchemyError as exc: diff --git a/app/api/schemas.py b/app/api/schemas.py index d3edb7b..1a667b8 100644 --- a/app/api/schemas.py +++ b/app/api/schemas.py @@ -48,6 +48,10 @@ class AvaliarVeiculoTrocaRequest(BaseModel): class AgendarRevisaoRequest(BaseModel): placa: str data_hora: str + modelo: str + ano: int + km: int + revisao_previa_concessionaria: bool user_id: Optional[int] = None @@ -55,3 +59,9 @@ class CancelarPedidoRequest(BaseModel): numero_pedido: str motivo: str user_id: Optional[int] = None + + +class RealizarPedidoRequest(BaseModel): + cpf: str + valor_veiculo: float + user_id: Optional[int] = None diff --git a/app/db/tool_seed.py b/app/db/tool_seed.py index 967fe3f..e1ba772 100644 --- a/app/db/tool_seed.py +++ b/app/db/tool_seed.py @@ -90,9 +90,12 @@ def get_tools_definitions(): "name": "agendar_revisao", "description": ( "Use esta ferramenta quando o cliente quiser marcar uma revisao ou " - "manutencao para o veiculo. Ela recebe a placa e a data/hora desejada, " - "cria um agendamento simulado e retorna um identificador, alem do " - "status do agendamento." + "manutencao para o veiculo. Ela recebe a placa, data/hora desejada, " + "modelo, ano, quilometragem e se ja houve revisao anterior na " + "concessionaria. Com esses dados, calcula o valor da revisao e cria " + "um agendamento simulado. Nao permite dois agendamentos no mesmo " + "horario; se o horario estiver ocupado, devolve sugestao do proximo " + "horario disponivel." ), "parameters": { "type": "object", @@ -103,10 +106,59 @@ def get_tools_definitions(): }, "data_hora": { "type": "string", - "description": "Data e hora desejada para a revisao, em formato ISO 8601 (por exemplo, '2026-03-10T09:00:00-03:00').", + "description": ( + "Data e hora desejada para a revisao. Aceita formatos como " + "'2026-03-10T09:00:00-03:00', '2026-03-10 09:00', " + "'10/03/2026 09:00' e '10/03/2026 as 09:00'." + ), + }, + "modelo": { + "type": "string", + "description": "Modelo do veiculo (por exemplo: Onix, Corolla, Compass).", + }, + "ano": { + "type": "integer", + "description": "Ano do veiculo.", + }, + "km": { + "type": "integer", + "description": "Quilometragem atual do veiculo.", + }, + "revisao_previa_concessionaria": { + "type": "boolean", + "description": "Informe true se o veiculo ja fez revisao na concessionaria, senao false.", + }, + }, + "required": [ + "placa", + "data_hora", + "modelo", + "ano", + "km", + "revisao_previa_concessionaria", + ], + }, + }, + { + "name": "realizar_pedido", + "description": ( + "Use esta ferramenta quando o cliente quiser efetivar uma compra/pedido. " + "Ela recebe CPF e valor do veiculo, valida credito e, se aprovado, cria " + "um novo pedido com numero unico." + ), + "parameters": { + "type": "object", + "properties": { + "cpf": { + "type": "string", + "description": "CPF do cliente, com ou sem formatacao.", + }, + "valor_veiculo": { + "type": "number", + "description": "Valor do veiculo em reais (BRL) para gerar o pedido.", }, }, - "required": ["placa", "data_hora"], + "required": ["cpf", "valor_veiculo"], }, }, { diff --git a/app/integrations/telegram_satellite_service.py b/app/integrations/telegram_satellite_service.py index 0eda0a0..54a2e00 100644 --- a/app/integrations/telegram_satellite_service.py +++ b/app/integrations/telegram_satellite_service.py @@ -1,5 +1,7 @@ import asyncio import logging +import os +import tempfile from typing import Any, Dict, List import aiohttp @@ -15,6 +17,38 @@ from app.services.user_service import UserService logger = logging.getLogger(__name__) +def _acquire_single_instance_lock(lock_name: str): + """ + Garante apenas uma instancia local do satelite por lock de arquivo. + Retorna o handle do arquivo para manter o lock vivo. + """ + lock_path = os.path.join(tempfile.gettempdir(), lock_name) + handle = open(lock_path, "a+") + + try: + import msvcrt + + try: + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + except OSError: + handle.close() + raise RuntimeError( + "Ja existe uma instancia do Telegram satellite em execucao neste host." + ) + return handle + except ImportError: + import fcntl + + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + handle.close() + raise RuntimeError( + "Ja existe uma instancia do Telegram satellite em execucao neste host." + ) + return handle + + class TelegramSatelliteService: """ Interface satelite para Telegram. @@ -26,6 +60,7 @@ class TelegramSatelliteService: self.base_url = f"https://api.telegram.org/bot{token}" self.polling_timeout = settings.telegram_polling_timeout self.request_timeout = settings.telegram_request_timeout + self._last_update_id = -1 async def run(self) -> None: """Inicia loop de long polling para consumir atualizacoes do bot.""" @@ -34,12 +69,47 @@ class TelegramSatelliteService: timeout = aiohttp.ClientTimeout(total=self.request_timeout) async with aiohttp.ClientSession(timeout=timeout) as session: + offset = await self._initialize_offset(session=session) while True: updates = await self._get_updates(session=session, offset=offset) for update in updates: - offset = update["update_id"] + 1 + 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) + 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. + """ + payload: Dict[str, Any] = { + "timeout": 0, + "limit": 100, + "allowed_updates": ["message"], + } + async with session.post(f"{self.base_url}/getUpdates", json=payload) as response: + data = await response.json() + if not data.get("ok"): + logger.warning("Falha ao inicializar offset no Telegram: %s", data) + return None + + updates = data.get("result", []) + if not updates: + return None + + last_id = max(u.get("update_id", -1) for u in updates if isinstance(u.get("update_id"), int)) + 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)) + return last_id + 1 + async def _get_updates( self, session: aiohttp.ClientSession, @@ -48,6 +118,7 @@ class TelegramSatelliteService: """Busca novas mensagens no Telegram a partir do offset informado.""" payload: Dict[str, Any] = { "timeout": self.polling_timeout, + "limit": 100, "allowed_updates": ["message"], } if offset is not None: @@ -134,9 +205,14 @@ async def main() -> None: if not token: raise RuntimeError("TELEGRAM_BOT_TOKEN nao configurado.") + lock_handle = _acquire_single_instance_lock("orquestrador_telegram_satellite.lock") + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") service = TelegramSatelliteService(token=token) - await service.run() + try: + await service.run() + finally: + lock_handle.close() if __name__ == "__main__": diff --git a/app/services/handlers.py b/app/services/handlers.py index 5fcd9a7..1fb1fb8 100644 --- a/app/services/handlers.py +++ b/app/services/handlers.py @@ -1,13 +1,17 @@ -from datetime import datetime +from datetime import datetime, timedelta, timezone import hashlib import re +from uuid import uuid4 from typing import Any, Dict, List, Optional from fastapi import HTTPException +from sqlalchemy import func from app.db.mock_database import SessionMockLocal from app.db.mock_models import Customer, Order, ReviewSchedule, Vehicle +# Nesse arquivo eu faço a limpeza dos dados para persisti-los no DB + def normalize_cpf(value: str) -> str: """Normaliza CPF removendo qualquer caractere nao numerico.""" @@ -34,6 +38,63 @@ def _stable_int(seed_text: str) -> int: return int(digest[:16], 16) +def _parse_bool(value: Any, default: bool = False) -> bool: + """Converte valores textuais/booleanos comuns para bool.""" + if isinstance(value, bool): + return value + if value is None: + return default + text = str(value).strip().lower() + if text in {"true", "1", "sim", "yes", "y"}: + return True + if text in {"false", "0", "nao", "no", "n"}: + return False + return default + + +def _base_review_price_by_model(modelo: str) -> float: + """Define valor base da revisao com heuristica simples pelo modelo/categoria textual.""" + text = (modelo or "").lower() + premium_brands = ("bmw", "audi", "mercedes", "volvo", "land rover", "lexus") + if any(brand in text for brand in premium_brands): + return 1200.0 + if any(tag in text for tag in ("suv", "pickup", "caminhonete")): + return 900.0 + if "sedan" in text: + return 750.0 + if "hatch" in text: + return 650.0 + return 700.0 + + +def _calculate_review_price( + modelo: str, + ano: int, + km: int, + revisao_previa_concessionaria: bool, +) -> float: + """Calcula valor da revisao com base em modelo, idade, quilometragem e historico.""" + base = _base_review_price_by_model(modelo) + ano_atual = datetime.now().year + idade = max(0, ano_atual - int(ano)) + fator_idade = 1.0 + min(idade * 0.02, 0.30) + + km_int = max(0, int(km)) + if km_int <= 20000: + adicional_km = 0.0 + elif km_int <= 60000: + adicional_km = 150.0 + elif km_int <= 100000: + adicional_km = 300.0 + else: + adicional_km = 500.0 + + subtotal = (base * fator_idade) + adicional_km + if revisao_previa_concessionaria: + subtotal *= 0.90 + return round(max(subtotal, 300.0), 2) + + async def consultar_estoque( preco_max: Optional[float] = None, categoria: Optional[str] = None, @@ -122,21 +183,166 @@ async def avaliar_veiculo_troca(modelo: str, ano: int, km: int) -> Dict[str, Any } -async def agendar_revisao(placa: str, data_hora: str, user_id: Optional[int] = None) -> Dict[str, Any]: +def _parse_tzinfo(offset: Optional[str]) -> Optional[timezone]: + if not offset: + return None + if offset == "Z": + return timezone.utc + sign = 1 if offset[0] == "+" else -1 + hours = int(offset[1:3]) + minutes = int(offset[4:6]) + return timezone(sign * timedelta(hours=hours, minutes=minutes)) + + +def _parse_data_hora_revisao(value: str) -> datetime: + text = (value or "").strip() + if not text: + raise ValueError("data_hora vazia") + + normalized = re.sub(r"\s+[aA]s\s+", " ", text) + iso_candidates = [text, normalized] + for candidate in iso_candidates: + try: + return datetime.fromisoformat(candidate.replace("Z", "+00:00")) + except ValueError: + pass + + patterns = ( + r"^(?P\d{1,2})[/-](?P\d{1,2})[/-](?P\d{4})\s+" + r"(?P\d{1,2}):(?P\d{2})(?::(?P\d{2}))?" + r"(?:\s*(?PZ|[+-]\d{2}:\d{2}))?$", + r"^(?P\d{4})[/-](?P\d{1,2})[/-](?P\d{1,2})\s+" + r"(?P\d{1,2}):(?P\d{2})(?::(?P\d{2}))?" + r"(?:\s*(?PZ|[+-]\d{2}:\d{2}))?$", + ) + + for pattern in patterns: + match = re.match(pattern, normalized) + if not match: + continue + parts = match.groupdict() + second = int(parts["second"] or 0) + tzinfo = _parse_tzinfo(parts.get("tz")) + return datetime( + year=int(parts["year"]), + month=int(parts["month"]), + day=int(parts["day"]), + hour=int(parts["hour"]), + minute=int(parts["minute"]), + second=second, + tzinfo=tzinfo, + ) + + raise ValueError("formato invalido") + + +def _normalize_review_slot(value: datetime) -> datetime: + """Normaliza data/hora de revisao para granularidade de minuto.""" + return value.replace(second=0, microsecond=0) + + +def _format_datetime_pt_br(value: datetime) -> str: + """Formata datetime em padrao brasileiro para mensagens ao usuario.""" + return value.strftime("%d/%m/%Y as %H:%M") + + +def _find_next_available_review_slot( + db, + requested_dt: datetime, + max_attempts: int = 16, + step_minutes: int = 30, +) -> Optional[datetime]: + """ + Procura o proximo horario livre avancando em blocos de 30 minutos. + Retorna None se nao encontrar dentro da janela de tentativa. + """ + for attempt in range(1, max_attempts + 1): + candidate = requested_dt + timedelta(minutes=step_minutes * attempt) + ocupado = ( + db.query(ReviewSchedule) + .filter(ReviewSchedule.data_hora == candidate) + .filter(func.lower(ReviewSchedule.status) != "cancelado") + .first() + ) + if not ocupado: + return candidate + return None + + +async def agendar_revisao( + placa: str, + data_hora: str, + modelo: str, + ano: int, + km: int, + revisao_previa_concessionaria: bool, + user_id: Optional[int] = None, +) -> Dict[str, Any]: """Cria ou reaproveita agendamento de revisao a partir de placa e data/hora.""" try: - dt = datetime.fromisoformat(data_hora.replace("Z", "+00:00")) + ano_int = int(ano) + km_int = int(km) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="ano e km devem ser valores inteiros validos.") + + ano_atual = datetime.now().year + if ano_int < 1980 or ano_int > ano_atual + 1: + raise HTTPException(status_code=400, detail=f"ano invalido. Informe entre 1980 e {ano_atual + 1}.") + if km_int < 0: + raise HTTPException(status_code=400, detail="km invalido. Informe um valor maior ou igual a zero.") + + try: + dt = _parse_data_hora_revisao(data_hora) except ValueError: raise HTTPException( status_code=400, - detail="data_hora invalida. Use formato ISO 8601, por exemplo: 2026-03-10T09:00:00-03:00", + detail=( + "data_hora invalida. Exemplos aceitos: " + "2026-03-10T09:00:00-03:00, 2026-03-10 09:00, 10/03/2026 09:00, " + "10/03/2026 as 09:00." + ), ) - - entropy = hashlib.md5(f"{user_id}:{placa}:{data_hora}".encode("utf-8")).hexdigest()[:8].upper() + dt = _normalize_review_slot(dt) + + placa_normalizada = placa.upper() + revisao_previa = _parse_bool(revisao_previa_concessionaria) + valor_revisao = _calculate_review_price( + modelo=modelo, + ano=ano_int, + km=km_int, + revisao_previa_concessionaria=revisao_previa, + ) + dt_canonical = dt.isoformat() + entropy = hashlib.md5(f"{user_id}:{placa_normalizada}:{dt_canonical}".encode("utf-8")).hexdigest()[:8].upper() protocolo = f"REV-{dt.strftime('%Y%m%d')}-{entropy}" db = SessionMockLocal() try: + conflito_horario = ( + db.query(ReviewSchedule) + .filter(ReviewSchedule.data_hora == dt) + .filter(func.lower(ReviewSchedule.status) != "cancelado") + .first() + ) + if conflito_horario: + proximo_horario = _find_next_available_review_slot(db=db, requested_dt=dt) + if proximo_horario: + raise HTTPException( + status_code=409, + detail=( + f"O horario {_format_datetime_pt_br(dt)} ja esta ocupado. " + f"Posso agendar em {_format_datetime_pt_br(proximo_horario)} " + f"(ISO: {proximo_horario.isoformat()})." + ), + ) + raise HTTPException( + status_code=409, + detail=( + f"O horario {_format_datetime_pt_br(dt)} ja esta ocupado e nao encontrei " + "disponibilidade nas proximas 8 horas." + ), + ) + existente = db.query(ReviewSchedule).filter(ReviewSchedule.protocolo == protocolo).first() if existente: return { @@ -145,12 +351,17 @@ async def agendar_revisao(placa: str, data_hora: str, user_id: Optional[int] = N "placa": existente.placa, "data_hora": existente.data_hora.isoformat(), "status": existente.status, + "modelo": modelo, + "ano": ano_int, + "km": km_int, + "revisao_previa_concessionaria": revisao_previa, + "valor_revisao": valor_revisao, } agendamento = ReviewSchedule( protocolo=protocolo, user_id=user_id, - placa=placa.upper(), + placa=placa_normalizada, data_hora=dt, status="agendado", ) @@ -164,6 +375,11 @@ async def agendar_revisao(placa: str, data_hora: str, user_id: Optional[int] = N "placa": agendamento.placa, "data_hora": agendamento.data_hora.isoformat(), "status": agendamento.status, + "modelo": modelo, + "ano": ano_int, + "km": km_int, + "revisao_previa_concessionaria": revisao_previa, + "valor_revisao": valor_revisao, } finally: db.close() @@ -221,3 +437,41 @@ async def cancelar_pedido(numero_pedido: str, motivo: str, user_id: Optional[int } finally: db.close() + + +async def realizar_pedido(cpf: str, valor_veiculo: float, user_id: Optional[int] = None) -> Dict[str, Any]: + """Cria um novo pedido de compra quando o cliente estiver aprovado para o valor informado.""" + cpf_norm = normalize_cpf(cpf) + avaliacao = await validar_cliente_venda(cpf=cpf_norm, valor_veiculo=valor_veiculo) + if not avaliacao.get("aprovado"): + raise HTTPException( + status_code=400, + detail=( + "Cliente nao aprovado para este valor. " + f"Limite disponivel: R$ {avaliacao.get('limite_credito', 0):.2f}." + ), + ) + + numero_pedido = f"PED-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{uuid4().hex[:6].upper()}" + db = SessionMockLocal() + try: + pedido = Order( + numero_pedido=numero_pedido, + user_id=user_id, + cpf=cpf_norm, + status="Ativo", + ) + db.add(pedido) + db.commit() + db.refresh(pedido) + + return { + "numero_pedido": pedido.numero_pedido, + "user_id": pedido.user_id, + "cpf": pedido.cpf, + "status": pedido.status, + "valor_veiculo": valor_veiculo, + "aprovado_credito": True, + } + finally: + db.close() diff --git a/app/services/orquestrador_service.py b/app/services/orquestrador_service.py index aedabc6..f77ba48 100644 --- a/app/services/orquestrador_service.py +++ b/app/services/orquestrador_service.py @@ -1,3 +1,6 @@ +import re +from datetime import datetime, timedelta + from fastapi import HTTPException from sqlalchemy.orm import Session @@ -6,6 +9,21 @@ from app.services.tool_registry import ToolRegistry class OrquestradorService: + # Memoria temporaria de confirmacao quando a API sugere novo horario (conflito 409). + PENDING_REVIEW_CONFIRMATIONS: dict[int, dict] = {} + PENDING_REVIEW_TTL_MINUTES = 30 # Pode ser alterado por uma variável de configuração caso o sistema cresça + # Rascunho por usuario para juntar dados de revisao enviados em mensagens separadas. + PENDING_REVIEW_DRAFTS: dict[int, dict] = {} + PENDING_REVIEW_DRAFT_TTL_MINUTES = 30 + REVIEW_REQUIRED_FIELDS = ( + "placa", + "data_hora", + "modelo", + "ano", + "km", + "revisao_previa_concessionaria", + ) + LOW_VALUE_RESPONSES = { "certo.", "certo", @@ -24,6 +42,17 @@ class OrquestradorService: async def handle_message(self, message: str, user_id: int | None = None) -> str: """Processa mensagem, executa tool quando necessario e retorna resposta final.""" + # 1) Se houver sugestao pendente de horario e o usuario confirmou ("pode/sim"), + # agenda direto no horario sugerido. + confirmation_response = await self._try_confirm_pending_review(message=message, user_id=user_id) + if confirmation_response: + return confirmation_response + # 2) Fluxo de coleta incremental de dados da revisao (slot filling). + # Evita pedir tudo de novo quando o usuario responde em partes. + review_response = await self._try_collect_and_schedule_review(message=message, user_id=user_id) + if review_response: + return review_response + tools = self.registry.get_tools() llm_result = await self.llm.generate_response( @@ -48,6 +77,12 @@ class OrquestradorService: user_id=user_id, ) except HTTPException as exc: + self._capture_review_confirmation_suggestion( + tool_name=tool_name, + arguments=arguments, + exc=exc, + user_id=user_id, + ) return self._http_exception_detail(exc) final_response = await self.llm.generate_response( @@ -73,6 +108,249 @@ class OrquestradorService: def _is_low_value_response(self, text: str) -> bool: return text.strip().lower() in self.LOW_VALUE_RESPONSES + def _is_review_intent(self, text: str) -> bool: + lowered = (text or "").lower() + return any(k in lowered for k in ("revis", "manutenc", "agendar", "horario")) + + def _extract_review_fields(self, text: str) -> dict: + # Extrai os campos de revisao com regex simples para reduzir dependencia do LLM + # em mensagens curtas de follow-up. + lowered = (text or "").lower() + extracted: dict = {} + + placa_match = re.search(r"\b([A-Za-z]{3}[0-9][A-Za-z0-9][0-9]{2}|[A-Za-z]{3}[0-9]{4})\b", text or "") + if placa_match: + extracted["placa"] = placa_match.group(1).upper() + + dt_match = re.search( + r"(\d{1,2}[/-]\d{1,2}[/-]\d{4}\s*(?:as|às)?\s*\d{1,2}:\d{2})|" + r"(\d{4}[/-]\d{1,2}[/-]\d{1,2}\s*(?:as|às)?\s*\d{1,2}:\d{2})|" + r"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:\d{2})?)", + lowered, + ) + if dt_match: + value = next((g for g in dt_match.groups() if g), None) + if value: + extracted["data_hora"] = re.sub(r"\s+às\s+", " as ", value, flags=re.IGNORECASE) + + modelo_match = re.search(r"modelo\s+([a-z0-9][a-z0-9\s\-]{1,40})", lowered) + if modelo_match: + modelo = modelo_match.group(1).strip(" ,.;") + if modelo: + extracted["modelo"] = modelo.title() + + ano_match = re.search(r"(?:ano\s*)?(19\d{2}|20\d{2})\b", lowered) + if ano_match: + extracted["ano"] = int(ano_match.group(1)) + + km_match = re.search(r"(\d{1,3}(?:[.\s]\d{3})*|\d+)\s*km\b", lowered) + if km_match: + km_text = re.sub(r"[.\s]", "", km_match.group(1)) + if km_text.isdigit(): + extracted["km"] = int(km_text) + + if any(k in lowered for k in ("ja fiz revisao", "já fiz revisão", "ja fez revisao", "já fez revisão")): + extracted["revisao_previa_concessionaria"] = True + elif any( + k in lowered + for k in ( + "nao fiz revisao", + "não fiz revisão", + "primeira revisao", + "primeira revisão", + "nunca fiz revisao", + "nunca fiz revisão", + ) + ): + extracted["revisao_previa_concessionaria"] = False + + return extracted + + def _render_missing_review_fields_prompt(self, missing_fields: list[str]) -> str: + labels = { + "placa": "a placa do veiculo", + "data_hora": "a data e hora desejada para a revisao", + "modelo": "o modelo do veiculo", + "ano": "o ano do veiculo", + "km": "a quilometragem atual (km)", + "revisao_previa_concessionaria": "se ja fez revisao na concessionaria (sim/nao)", + } + itens = [f"- {labels[field]}" for field in missing_fields] + return "Para agendar sua revisao, preciso dos dados abaixo:\n" + "\n".join(itens) + + # Em vez de tentar entender tudo de uma vez, o bot mantém um "estado" do que já sabe e vai perguntando apenas o que falta (os "slots" vazios) até que a tarefa possa ser completada. + async def _try_collect_and_schedule_review(self, message: str, user_id: int | None) -> str | None: + if user_id is None: + return None + + # Reaproveita rascunho anterior do usuario, se ainda estiver valido. + draft = self.PENDING_REVIEW_DRAFTS.get(user_id) + if draft and draft["expires_at"] < datetime.utcnow(): + self.PENDING_REVIEW_DRAFTS.pop(user_id, None) + draft = None + + extracted = self._extract_review_fields(message) + has_intent = self._is_review_intent(message) + + # Sem intencao de revisao e sem rascunho aberto: nao interfere no fluxo normal. + if not has_intent and draft is None: + return None + + if draft is None: + draft = {"payload": {}, "expires_at": datetime.utcnow() + timedelta(minutes=self.PENDING_REVIEW_DRAFT_TTL_MINUTES)} + + # Permite o usuario "abortar" a coleta atual. + if "cancelar" in (message or "").lower() and draft["payload"]: + self.PENDING_REVIEW_DRAFTS.pop(user_id, None) + return None + + # Merge incremental: apenas atualiza os campos detectados na mensagem atual. + draft["payload"].update(extracted) + draft["expires_at"] = datetime.utcnow() + timedelta(minutes=self.PENDING_REVIEW_DRAFT_TTL_MINUTES) + self.PENDING_REVIEW_DRAFTS[user_id] = draft + + # Enquanto faltar campo obrigatorio, responde de forma deterministica + # (sem depender do LLM para lembrar contexto). + missing = [field for field in self.REVIEW_REQUIRED_FIELDS if field not in draft["payload"]] + if missing: + return self._render_missing_review_fields_prompt(missing) + + try: + # Com payload completo, executa a tool de agendamento. + tool_result = await self.registry.execute( + "agendar_revisao", + draft["payload"], + user_id=user_id, + ) + except HTTPException as exc: + # Se houver conflito com sugestao de horario, salva para confirmar com "pode/sim". + self._capture_review_confirmation_suggestion( + tool_name="agendar_revisao", + arguments=draft["payload"], + exc=exc, + user_id=user_id, + ) + return self._http_exception_detail(exc) + finally: + # Limpa o rascunho apos tentativa final para evitar estado sujo. + self.PENDING_REVIEW_DRAFTS.pop(user_id, None) + + return self._fallback_format_tool_result("agendar_revisao", tool_result) + + def _is_affirmative_message(self, text: str) -> bool: + normalized = (text or "").strip().lower() + normalized = re.sub(r"[.!?,;:]+$", "", normalized) + return normalized in {"sim", "pode", "ok", "confirmo", "aceito", "fechado", "pode sim"} + + def _is_negative_message(self, text: str) -> bool: + normalized = (text or "").strip().lower() + normalized = re.sub(r"[.!?,;:]+$", "", normalized) + return ( + normalized in {"nao", "não", "nao quero", "não quero", "prefiro outro", "outro horario", "outro horário"} + or normalized.startswith("nao") + or normalized.startswith("não") + ) + + def _extract_time_only(self, text: str) -> str | None: + match = re.search(r"\b([01]?\d|2[0-3]):([0-5]\d)\b", text or "") + if not match: + return None + return f"{int(match.group(1)):02d}:{match.group(2)}" + + def _merge_date_with_time(self, base_iso: str, new_time_hhmm: str) -> str | None: + try: + 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: + return None + + 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, str) else "" + match = re.search(r"ISO:\s*([^)]+)\)", detail) + if not match: + return + suggested_iso = match.group(1).strip() + payload = dict(arguments or {}) + if not payload.get("placa"): + return + payload["data_hora"] = suggested_iso + self.PENDING_REVIEW_CONFIRMATIONS[user_id] = { + "payload": payload, + "expires_at": datetime.utcnow() + timedelta(minutes=self.PENDING_REVIEW_TTL_MINUTES), + } + + async def _try_confirm_pending_review(self, message: str, user_id: int | None) -> str | None: + if user_id is None: + return None + pending = self.PENDING_REVIEW_CONFIRMATIONS.get(user_id) + if not pending: + return None + + time_only = self._extract_time_only(message) + if self._is_negative_message(message) or time_only: + # Se o usuario recusar a sugestao e informar novo horario, reaproveita + # o payload pendente com a nova data/hora. + extracted = self._extract_review_fields(message) + 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.PENDING_REVIEW_CONFIRMATIONS.pop(user_id, None) + 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.registry.execute( + "agendar_revisao", + payload, + user_id=user_id, + ) + except HTTPException as exc: + self.PENDING_REVIEW_CONFIRMATIONS.pop(user_id, None) + self._capture_review_confirmation_suggestion( + tool_name="agendar_revisao", + arguments=payload, + exc=exc, + user_id=user_id, + ) + return self._http_exception_detail(exc) + + self.PENDING_REVIEW_CONFIRMATIONS.pop(user_id, None) + return self._fallback_format_tool_result("agendar_revisao", tool_result) + + if not self._is_affirmative_message(message): + return None + if pending["expires_at"] < datetime.utcnow(): + self.PENDING_REVIEW_CONFIRMATIONS.pop(user_id, None) + return None + + try: + tool_result = await self.registry.execute( + "agendar_revisao", + pending["payload"], + user_id=user_id, + ) + except HTTPException as exc: + self.PENDING_REVIEW_CONFIRMATIONS.pop(user_id, None) + return self._http_exception_detail(exc) + + self.PENDING_REVIEW_CONFIRMATIONS.pop(user_id, None) + return self._fallback_format_tool_result("agendar_revisao", tool_result) + def _is_operational_query(self, message: str) -> bool: text = message.lower() keywords = ( @@ -89,6 +367,9 @@ class OrquestradorService: "revis", "placa", "cancelar pedido", + "comprar", + "compra", + "realizar pedido", "pedido", ) return any(k in text for k in keywords) @@ -98,7 +379,7 @@ class OrquestradorService: 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 ou cancelamento de pedido), use a tool correta. " + "avaliacao de troca, agendamento de revisao, realizacao ou cancelamento de pedido), use a tool correta. " "Se faltar parametro obrigatorio para a tool, responda em texto pedindo apenas o que falta.\n\n" f"{user_context}" f"Mensagem do usuario: {user_message}" @@ -147,6 +428,19 @@ class OrquestradorService: status = tool_result.get("status", "N/A") return f"Pedido {numero} atualizado com status {status}." + if tool_name == "realizar_pedido" and isinstance(tool_result, dict): + numero = tool_result.get("numero_pedido", "N/A") + return f"Pedido {numero} criado com sucesso." + + if tool_name == "agendar_revisao" and isinstance(tool_result, dict): + placa = tool_result.get("placa", "N/A") + data_hora = tool_result.get("data_hora", "N/A") + protocolo = tool_result.get("protocolo", "N/A") + valor = tool_result.get("valor_revisao") + if isinstance(valor, (int, float)): + return f"Revisao agendada para placa {placa} em {data_hora}. Valor estimado: R$ {valor:.2f}. Protocolo: {protocolo}." + return f"Revisao agendada para placa {placa} em {data_hora}. Protocolo: {protocolo}." + if tool_name == "validar_cliente_venda" and isinstance(tool_result, dict): aprovado = tool_result.get("aprovado") return "Cliente aprovado para financiamento." if aprovado else "Cliente nao aprovado para financiamento." diff --git a/app/services/tool_registry.py b/app/services/tool_registry.py index 38eff0f..da40367 100644 --- a/app/services/tool_registry.py +++ b/app/services/tool_registry.py @@ -10,6 +10,7 @@ from app.services.handlers import ( avaliar_veiculo_troca, cancelar_pedido, consultar_estoque, + realizar_pedido, validar_cliente_venda, ) @@ -20,6 +21,7 @@ HANDLERS: Dict[str, Callable] = { "avaliar_veiculo_troca": avaliar_veiculo_troca, "agendar_revisao": agendar_revisao, "cancelar_pedido": cancelar_pedido, + "realizar_pedido": realizar_pedido, }