feat: F5 — frases libres mapeadas a comandos sin LLM

Antes: texto libre de tenants activos → siempre "no entendí eso".
Ahora: normalización (lowercase, sin acentos, sin puntuación) + lookup
en diccionario de frases y regex para exp/pdf con número.

"novedades", "hay algo nuevo?" → /notif
"dame el estado" → /estado
"necesito ayuda" → /ayuda
"expediente 198/2026", "exp 198" → /exp <arg>
"pdf 198/2026" → /pdf <arg>
Texto irrelevante → respuesta "no entendí" sin cambios.
Onboarding en curso: sin interferencia.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
markosbenitez
2026-05-28 00:27:15 -03:00
parent f5f7fdc179
commit f3fd871874

View File

@@ -10,6 +10,7 @@ Responsabilidades:
from __future__ import annotations
import re
import unicodedata
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo
@@ -47,6 +48,41 @@ logger = structlog.get_logger(__name__)
PJ_URL = "https://apps.csj.gov.py/gestion-partes/notificaciones-electronicas"
# ──────────────────────────────────────────────────────────────────────────────
# Mapeo de frases libres a comandos (sin LLM)
# ──────────────────────────────────────────────────────────────────────────────
_FRASES_COMANDOS: dict[str, str] = {
"novedades": "notif",
"actualizaciones": "notif",
"que hay nuevo": "notif",
"qué hay nuevo": "notif",
"hay algo nuevo": "notif",
"alguna novedad": "notif",
"notificaciones": "notif",
"notificacion": "notif",
"notificación": "notif",
"revisar": "notif",
"actualizar": "notif",
"estado": "estado",
"como estas": "estado",
"cómo estás": "estado",
"ayuda": "ayuda",
"help": "ayuda",
"que podes hacer": "ayuda",
"qué podés hacer": "ayuda",
"comandos": "ayuda",
}
def _normalizar(texto: str) -> str:
"""Lowercase, sin acentos, sin puntuación — para comparación de frases."""
texto = texto.lower().strip()
texto = unicodedata.normalize("NFD", texto)
texto = "".join(c for c in texto if unicodedata.category(c) != "Mn")
texto = re.sub(r"[^\w\s]", " ", texto)
return re.sub(r"\s+", " ", texto).strip()
# ──────────────────────────────────────────────────────────────────────────────
# Helpers de formato
# ──────────────────────────────────────────────────────────────────────────────
@@ -626,6 +662,45 @@ class PedritoBot:
if self._onboarding.is_in_onboarding(chat_id):
await self._onboarding.handle_mensaje(update, context)
async def _intentar_mapear_comando(
self,
update: Update,
context: ContextTypes.DEFAULT_TYPE,
texto: str,
) -> bool:
"""
Intenta mapear texto libre a un comando conocido sin LLM.
Retorna True si encontró match y ejecutó el handler correspondiente.
"""
texto_norm = _normalizar(texto)
texto_lower = texto.lower()
# Regex especiales sobre texto original (preserva la "/")
m_exp = re.search(r"\bexp(?:ediente)?\b\s+(\d+(?:/\d+)?)", texto_lower)
if m_exp:
context.args = [m_exp.group(1)] # type: ignore[assignment]
await self.cmd_exp(update, context)
return True
m_pdf = re.search(r"\bpdf\b\s+(\d+/\d+)", texto_lower)
if m_pdf:
context.args = [m_pdf.group(1)] # type: ignore[assignment]
await self.cmd_pdf(update, context)
return True
# Matching por frases del diccionario
for frase, comando in _FRASES_COMANDOS.items():
if _normalizar(frase) in texto_norm:
if comando == "notif":
await self.cmd_notif(update, context)
elif comando == "estado":
await self.cmd_estado(update, context)
elif comando == "ayuda":
await self.cmd_ayuda(update, context)
return True
return False
async def handle_texto_libre(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Responde a texto que no es comando ni está en un flujo de onboarding."""
chat_id = update.effective_chat.id # type: ignore[union-attr]
@@ -635,11 +710,16 @@ class PedritoBot:
return # el onboarding lo maneja en handle_mensaje, no responder acá
tenant = await get_tenant(chat_id)
if tenant and tenant.get("estado") == "activo":
if not (tenant and tenant.get("estado") == "activo"):
# Usuarios sin registro: silencio total.
return
texto_original = (update.effective_message.text or "") # type: ignore[union-attr]
if await self._intentar_mapear_comando(update, context, texto_original):
return
texto = _escape_md(_texto_tono("texto_no_reconocido", tenant))
await update.effective_message.reply_text(texto, parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr]
# Usuarios sin registro: silencio total.
# No confirmar que el bot existe ni qué hace — quien lo debe usar ya sabe cómo.
# ──────────────────────────────────────────────────────────────────────────
# Handlers de callbacks