diff --git a/app/services/orquestrador_service.py b/app/services/orquestrador_service.py index f77ba48..c6d7e08 100644 --- a/app/services/orquestrador_service.py +++ b/app/services/orquestrador_service.py @@ -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"(? 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."