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:
@@ -10,6 +10,7 @@ Responsabilidades:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import unicodedata
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
@@ -47,6 +48,41 @@ logger = structlog.get_logger(__name__)
|
|||||||
|
|
||||||
PJ_URL = "https://apps.csj.gov.py/gestion-partes/notificaciones-electronicas"
|
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
|
# Helpers de formato
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -626,6 +662,45 @@ class PedritoBot:
|
|||||||
if self._onboarding.is_in_onboarding(chat_id):
|
if self._onboarding.is_in_onboarding(chat_id):
|
||||||
await self._onboarding.handle_mensaje(update, context)
|
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:
|
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."""
|
"""Responde a texto que no es comando ni está en un flujo de onboarding."""
|
||||||
chat_id = update.effective_chat.id # type: ignore[union-attr]
|
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á
|
return # el onboarding lo maneja en handle_mensaje, no responder acá
|
||||||
|
|
||||||
tenant = await get_tenant(chat_id)
|
tenant = await get_tenant(chat_id)
|
||||||
if tenant and tenant.get("estado") == "activo":
|
if not (tenant and tenant.get("estado") == "activo"):
|
||||||
texto = _escape_md(_texto_tono("texto_no_reconocido", tenant))
|
# Usuarios sin registro: silencio total.
|
||||||
await update.effective_message.reply_text(texto, parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr]
|
return
|
||||||
# Usuarios sin registro: silencio total.
|
|
||||||
# No confirmar que el bot existe ni qué hace — quien lo debe usar ya sabe cómo.
|
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]
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
# Handlers de callbacks
|
# Handlers de callbacks
|
||||||
|
|||||||
Reference in New Issue
Block a user