🐛 fix(orquestrador): interromper cadastro de revisao ao trocar de intencao

main
parent f09081150f
commit af513f5583

@ -1,4 +1,5 @@
import re
import unicodedata
from datetime import datetime, timedelta
from fastapi import HTTPException
@ -11,7 +12,7 @@ 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
PENDING_REVIEW_TTL_MINUTES = 30 # Pode ser alterado por uma variavel de configuracao caso o sistema cresca
# Rascunho por usuario para juntar dados de revisao enviados em mensagens separadas.
PENDING_REVIEW_DRAFTS: dict[int, dict] = {}
PENDING_REVIEW_DRAFT_TTL_MINUTES = 30
@ -34,6 +35,17 @@ class OrquestradorService:
"claro.",
"claro",
}
DETERMINISTIC_RESPONSE_TOOLS = {
"consultar_estoque",
"validar_cliente_venda",
"avaliar_veiculo_troca",
"agendar_revisao",
"listar_agendamentos_revisao",
"cancelar_agendamento_revisao",
"editar_data_revisao",
"cancelar_pedido",
"realizar_pedido",
}
def __init__(self, db: Session):
"""Inicializa servicos de LLM e registro de tools para a sessao atual."""
@ -42,6 +54,8 @@ 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."""
routing_message = self._resolve_primary_intent_message(message=message, user_id=user_id)
# 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)
@ -56,13 +70,13 @@ class OrquestradorService:
tools = self.registry.get_tools()
llm_result = await self.llm.generate_response(
message=self._build_router_prompt(user_message=message, user_id=user_id),
message=self._build_router_prompt(user_message=routing_message, user_id=user_id),
tools=tools,
)
if not llm_result["tool_call"] and self._is_operational_query(message):
if not llm_result["tool_call"] and self._is_operational_query(routing_message):
llm_result = await self.llm.generate_response(
message=self._build_force_tool_prompt(user_message=message, user_id=user_id),
message=self._build_force_tool_prompt(user_message=routing_message, user_id=user_id),
tools=tools,
)
@ -85,6 +99,9 @@ class OrquestradorService:
)
return self._http_exception_detail(exc)
if self._should_use_deterministic_response(tool_name):
return self._fallback_format_tool_result(tool_name, tool_result)
final_response = await self.llm.generate_response(
message=self._build_result_prompt(
user_message=message,
@ -105,17 +122,97 @@ class OrquestradorService:
return "Entendi. Pode me dar mais detalhes para eu consultar corretamente?"
return text
def _reset_pending_review_states(self, user_id: int | None) -> None:
if user_id is None:
return
self.PENDING_REVIEW_DRAFTS.pop(user_id, None)
self.PENDING_REVIEW_CONFIRMATIONS.pop(user_id, None)
def _is_purchase_intent(self, text: str) -> bool:
lowered = self._normalize_text(text)
keywords = (
"comprar",
"compra",
"carro",
"carros",
"veiculo",
"veiculos",
"estoque",
)
return any(k in lowered for k in keywords)
def _has_review_protocol(self, text: str) -> bool:
return re.search(r"\brev-\d{8}-[a-z0-9]+\b", (text or "").lower()) is not None
def _resolve_primary_intent_message(self, message: str, user_id: int | None) -> str:
# Em mensagens mistas ("cancele ... agora quero comprar"), prioriza compra
# quando nao ha protocolo explicito de revisao.
if not self._is_purchase_intent(message):
return message
if not self._is_review_management_intent(message):
return message
if self._has_review_protocol(message):
return message
lowered = self._normalize_text(message)
buy_markers = ("agora quero comprar", "quero comprar", "comprar", "compra")
idx = -1
for marker in buy_markers:
pos = lowered.rfind(marker)
if pos > idx:
idx = pos
# Se identificar trecho de compra, usa apenas ele para rotear.
if idx >= 0:
self._reset_pending_review_states(user_id=user_id)
return (message or "")[idx:].strip() or message
return message
def _should_use_deterministic_response(self, tool_name: str) -> bool:
return tool_name in self.DETERMINISTIC_RESPONSE_TOOLS
def _normalize_text(self, text: str) -> str:
normalized = unicodedata.normalize("NFKD", text or "")
ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
return ascii_text.lower()
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:
def _is_review_scheduling_intent(self, text: str) -> bool:
lowered = self._normalize_text(text)
scheduling_keywords = (
"agendar",
"marcar revis",
"marcar manutenc",
"nova revis",
"quero agendar",
"quero marcar",
)
return any(k in lowered for k in scheduling_keywords)
def _is_review_management_intent(self, text: str) -> bool:
lowered = (text or "").lower()
return any(k in lowered for k in ("revis", "manutenc", "agendar", "horario"))
management_keywords = (
"agendamento",
"agendamentos",
"meus agendamentos",
"listar",
"mostrar",
"ver",
"cancelar revis",
"cancelar agendamento",
"remarcar",
"editar data",
"alterar data",
)
return any(k in lowered for k in management_keywords)
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()
lowered = self._normalize_text(text)
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 "")
@ -123,23 +220,45 @@ class OrquestradorService:
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{1,2}[/-]\d{1,2}[/-]\d{4}\s*(?:as)?\s*\d{1,2}:\d{2})|"
r"(\d{4}[/-]\d{1,2}[/-]\d{1,2}\s*(?:as)?\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)
extracted["data_hora"] = re.sub(r"\s+as\s+", " as ", value, flags=re.IGNORECASE)
else:
day_ref = None
if re.search(r"\bhoje\b", lowered):
day_ref = "hoje"
elif re.search(r"\bamanh[a-z]?\b", lowered):
day_ref = "amanha"
if day_ref:
time_match = re.search(r"\b(?:as\s*)?([01]?\d|2[0-3])(?::([0-5]\d))?\b", lowered)
if time_match:
hour = int(time_match.group(1))
minute = int(time_match.group(2) or "00")
target_date = datetime.now()
if day_ref == "amanha":
target_date = target_date + timedelta(days=1)
extracted["data_hora"] = f"{target_date.strftime('%d/%m/%Y')} {hour:02d}:{minute:02d}"
modelo_match = re.search(
r"modelo\s+([a-z0-9][a-z0-9\s\-]{1,40}?)(?=\s*(?:,|ano\b|\d{1,3}(?:[.\s]\d{3})*\s*km\b|$))",
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)
ano_match = re.search(r"\bano\s*(?:de\s*)?(19\d{2}|20\d{2})\b", lowered)
if not ano_match:
# Fallback sem a palavra "ano", evitando capturar o ano de uma data (ex.: 10/03/2026).
ano_match = re.search(r"(?<![/-])\b(19\d{2}|20\d{2})\b(?![/-])", lowered)
if ano_match:
extracted["ano"] = int(ano_match.group(1))
@ -149,17 +268,24 @@ class OrquestradorService:
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")):
if any(
k in lowered
for k in (
"ja fiz revisao",
"ja fez revisao",
"revisao previa na concessionaria",
"revisao previa em concessionaria",
)
):
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",
"sem revisao previa na concessionaria",
"sem revisao previa em concessionaria",
)
):
extracted["revisao_previa_concessionaria"] = False
@ -178,11 +304,19 @@ class OrquestradorService:
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.
# Em vez de tentar entender tudo de uma vez, o bot mantem um "estado" do que ja sabe e vai perguntando apenas o que falta (os "slots" vazios) ate 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
# Nao inicia slot-filling para fluxos de listar/cancelar/remarcar revisao.
# Nesses casos o roteamento via LLM + tools deve seguir normalmente.
if self._is_review_management_intent(message):
# Se o usuario mudou para gerenciamento de revisao, encerra
# qualquer coleta pendente de novo agendamento.
self.PENDING_REVIEW_DRAFTS.pop(user_id, 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():
@ -190,7 +324,13 @@ class OrquestradorService:
draft = None
extracted = self._extract_review_fields(message)
has_intent = self._is_review_intent(message)
has_intent = self._is_review_scheduling_intent(message)
# Se houver rascunho de revisao, mas o usuario mudou para outra
# intencao operacional (ex.: compra/estoque), descarta o rascunho.
if draft and not has_intent and self._is_operational_query(message):
self.PENDING_REVIEW_DRAFTS.pop(user_id, None)
return None
# Sem intencao de revisao e sem rascunho aberto: nao interfere no fluxo normal.
if not has_intent and draft is None:
@ -206,6 +346,16 @@ class OrquestradorService:
# Merge incremental: apenas atualiza os campos detectados na mensagem atual.
draft["payload"].update(extracted)
# Se o usuario responder apenas "sim/nao" no follow-up, preenche o slot booleano.
if (
"revisao_previa_concessionaria" not in draft["payload"]
and draft["payload"]
and not extracted
):
if self._is_affirmative_message(message):
draft["payload"]["revisao_previa_concessionaria"] = True
elif self._is_negative_message(message):
draft["payload"]["revisao_previa_concessionaria"] = False
draft["expires_at"] = datetime.utcnow() + timedelta(minutes=self.PENDING_REVIEW_DRAFT_TTL_MINUTES)
self.PENDING_REVIEW_DRAFTS[user_id] = draft
@ -238,17 +388,16 @@ class OrquestradorService:
return self._fallback_format_tool_result("agendar_revisao", tool_result)
def _is_affirmative_message(self, text: str) -> bool:
normalized = (text or "").strip().lower()
normalized = self._normalize_text(text).strip()
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 = self._normalize_text(text).strip()
normalized = re.sub(r"[.!?,;:]+$", "", normalized)
return (
normalized in {"nao", "não", "nao quero", "não quero", "prefiro outro", "outro horario", "outro horário"}
normalized in {"nao", "nao quero", "prefiro outro", "outro horario"}
or normalized.startswith("nao")
or normalized.startswith("não")
)
def _extract_time_only(self, text: str) -> str | None:
@ -365,8 +514,12 @@ class OrquestradorService:
"cpf",
"troca",
"revis",
"agendamento",
"agendamentos",
"remarcar",
"placa",
"cancelar pedido",
"cancelar revisao",
"comprar",
"compra",
"realizar pedido",
@ -404,7 +557,8 @@ class OrquestradorService:
user_context = f"Contexto de usuario autenticado: user_id={user_id}.\n" if user_id else ""
return (
"Responda ao usuario de forma objetiva usando o resultado da tool abaixo. "
"Nao invente dados. Se a lista vier vazia, diga explicitamente que nao encontrou resultados.\n\n"
"Nao invente dados. Se a lista vier vazia, diga explicitamente que nao encontrou resultados. "
"Retorne texto puro sem markdown, sem asteriscos, sem emojis e com linhas curtas.\n\n"
f"{user_context}"
f"Pergunta original: {user_message}\n"
f"Tool executada: {tool_name}\n"
@ -417,32 +571,134 @@ class OrquestradorService:
return detail
return "Nao foi possivel concluir a operacao solicitada."
def _format_datetime_for_chat(self, value: str) -> str:
try:
dt = datetime.fromisoformat((value or "").replace("Z", "+00:00"))
return dt.strftime("%d/%m/%Y %H:%M")
except Exception:
return value or "N/A"
def _format_currency_br(self, value) -> str:
try:
number = float(value)
formatted = f"{number:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
return f"R$ {formatted}"
except Exception:
return "N/A"
def _fallback_format_tool_result(self, tool_name: str, tool_result) -> str:
if tool_name == "consultar_estoque":
if tool_name == "consultar_estoque" and isinstance(tool_result, list):
if not tool_result:
return "Nao encontrei nenhum veiculo com os criterios informados."
return f"Encontrei {len(tool_result)} veiculo(s) com os criterios informados."
linhas = [f"Encontrei {len(tool_result)} veiculo(s):"]
for idx, item in enumerate(tool_result[:10], start=1):
modelo = item.get("modelo", "N/A")
categoria = item.get("categoria", "N/A")
preco = self._format_currency_br(item.get("preco"))
linhas.append(f"{idx}. {modelo} ({categoria}) - {preco}")
restantes = len(tool_result) - 10
if restantes > 0:
linhas.append(f"... e mais {restantes} veiculo(s).")
return "\n".join(linhas)
if tool_name == "cancelar_pedido" and isinstance(tool_result, dict):
numero = tool_result.get("numero_pedido", "N/A")
status = tool_result.get("status", "N/A")
return f"Pedido {numero} atualizado com status {status}."
motivo = tool_result.get("motivo")
linhas = [f"Pedido {numero} atualizado.", f"Status: {status}"]
if motivo:
linhas.append(f"Motivo: {motivo}")
return "\n".join(linhas)
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."
valor = self._format_currency_br(tool_result.get("valor_veiculo"))
return f"Pedido criado com sucesso.\nNumero: {numero}\nValor: {valor}"
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")
data_hora = self._format_datetime_for_chat(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}."
return (
"Revisao agendada com sucesso.\n"
f"Protocolo: {protocolo}\n"
f"Placa: {placa}\n"
f"Data/Hora: {data_hora}\n"
f"Valor estimado: {self._format_currency_br(valor)}"
)
return (
"Revisao agendada com sucesso.\n"
f"Protocolo: {protocolo}\n"
f"Placa: {placa}\n"
f"Data/Hora: {data_hora}"
)
if tool_name == "listar_agendamentos_revisao" and isinstance(tool_result, list):
if not tool_result:
return "Nao encontrei agendamentos de revisao para sua conta."
linhas = [f"Voce tem {len(tool_result)} agendamento(s):"]
for idx, item in enumerate(tool_result[:12], start=1):
protocolo = item.get("protocolo", "N/A")
placa = item.get("placa", "N/A")
data_hora = self._format_datetime_for_chat(item.get("data_hora", "N/A"))
status = item.get("status", "N/A")
linhas.append(f"{idx}) Protocolo: {protocolo}")
linhas.append(f"Placa: {placa}")
linhas.append(f"Data/Hora: {data_hora} | Status: {status}")
if idx < min(len(tool_result), 12):
linhas.append("")
restantes = len(tool_result) - 12
if restantes > 0:
if linhas and linhas[-1] != "":
linhas.append("")
linhas.append(f"... e mais {restantes} agendamento(s).")
return "\n".join(linhas)
if tool_name == "cancelar_agendamento_revisao" and isinstance(tool_result, dict):
protocolo = tool_result.get("protocolo", "N/A")
status = tool_result.get("status", "N/A")
placa = tool_result.get("placa", "N/A")
data_hora = self._format_datetime_for_chat(tool_result.get("data_hora", "N/A"))
return (
"Agendamento atualizado.\n"
f"Protocolo: {protocolo}\n"
f"Placa: {placa}\n"
f"Data/Hora: {data_hora}\n"
f"Status: {status}"
)
if tool_name == "editar_data_revisao" and isinstance(tool_result, dict):
protocolo = tool_result.get("protocolo", "N/A")
placa = tool_result.get("placa", "N/A")
data_hora = self._format_datetime_for_chat(tool_result.get("data_hora", "N/A"))
status = tool_result.get("status", "N/A")
return (
"Agendamento remarcado com sucesso.\n"
f"Protocolo: {protocolo}\n"
f"Placa: {placa}\n"
f"Nova data/hora: {data_hora}\n"
f"Status: {status}"
)
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."
limite = self._format_currency_br(tool_result.get("limite_credito"))
score = tool_result.get("score", "N/A")
cpf = tool_result.get("cpf", "N/A")
if aprovado:
return (
"Cliente aprovado para financiamento.\n"
f"CPF: {cpf}\n"
f"Score: {score}\n"
f"Limite: {limite}"
)
return (
"Cliente nao aprovado para financiamento.\n"
f"CPF: {cpf}\n"
f"Score: {score}\n"
f"Limite: {limite}"
)
return "Operacao concluida com sucesso."

Loading…
Cancel
Save