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
main
parent f061657ad3
commit 63040d472c

@ -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:

@ -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

@ -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"],
},
},
{

@ -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__":

@ -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<day>\d{1,2})[/-](?P<month>\d{1,2})[/-](?P<year>\d{4})\s+"
r"(?P<hour>\d{1,2}):(?P<minute>\d{2})(?::(?P<second>\d{2}))?"
r"(?:\s*(?P<tz>Z|[+-]\d{2}:\d{2}))?$",
r"^(?P<year>\d{4})[/-](?P<month>\d{1,2})[/-](?P<day>\d{1,2})\s+"
r"(?P<hour>\d{1,2}):(?P<minute>\d{2})(?::(?P<second>\d{2}))?"
r"(?:\s*(?P<tz>Z|[+-]\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()

@ -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."

@ -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,
}

Loading…
Cancel
Save