# Prompt para Claude Code — Sprint 4: Features 3, 5 y 1 Lee `CLAUDE.md` antes de empezar. Las invariantes de seguridad aplican. Implementar las tres features en orden: primero F5 (más simple), luego F3, luego F1. --- ## Feature 5 — Frases libres → comandos (sin LLM) ### Contexto El bot hoy responde a texto libre con "no entendí eso, mirá /ayuda". Hay frases obvias que debería mapear a comandos existentes sin necesidad de LLM. ### Implementación En `telegram_bot.py`, modificar `handle_texto_libre()` para que antes de responder "no entendí", intente mapear el texto a un comando conocido. ```python FRASES_COMANDOS = { # Notificaciones "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": "estado", "como estas": "estado", "cómo estás": "estado", # Ayuda "ayuda": "ayuda", "help": "ayuda", "que podes hacer": "ayuda", "qué podés hacer": "ayuda", "comandos": "ayuda", } ``` Lógica de matching: normalizar el texto del usuario (lowercase, sin acentos, sin signos de puntuación) y buscar si alguna frase del diccionario está contenida en el texto normalizado. Si hay match → ejecutar el handler correspondiente directamente. Si no hay match → respuesta actual ("no entendí eso..."). Casos especiales a detectar por regex: - "expediente 198/2026" o "exp 198" → ejecutar cmd_exp con ese argumento - "pdf 198/2026" → ejecutar cmd_pdf con ese argumento ### Verificación - [ ] "novedades" ejecuta lo mismo que /notif - [ ] "qué hay nuevo?" ejecuta lo mismo que /notif - [ ] "expediente 198/2026" ejecuta lo mismo que /exp 198/2026 - [ ] Texto completamente irrelevante ("hola qué tal") sigue respondiendo con el mensaje de ayuda - [ ] No interfiere con el flujo de onboarding --- ## Feature 3 — Extracto de PDF sin LLM ### Contexto Cuando llega una notificación con documento adjunto, el abogado quiere saber qué dice sin tener que descargar y abrir el PDF. La solución es extraer el texto del PDF con `pypdf` y mostrar las primeras líneas como extracto. Sin LLM. Sin interpretación. Texto crudo del documento con disclaimer. ### Nueva dependencia Agregar a `pyproject.toml`: ``` "pypdf>=4.0", ``` ### Nuevo módulo `pdf_extractor.py` ```python """ pdf_extractor.py — Extracción de texto de PDFs judiciales. Sin LLM. Sin interpretación. Solo extracción de texto crudo. El abogado lee el extracto y decide. Pedrito no interpreta. Los PDFs nunca se guardan en disco — se procesan en memoria. """ from pypdf import PdfReader import io MAX_CHARS = 600 # máximo de caracteres a mostrar def extraer_extracto(pdf_bytes: bytes) -> str | None: """ Extrae las primeras líneas de texto de un PDF judicial. Retorna el extracto como string, o None si el PDF no tiene texto extraíble (PDF escaneado, imagen, etc.) Los PDFs del PJ son nativos (no escaneados), así que pypdf funciona. """ try: reader = PdfReader(io.BytesIO(pdf_bytes)) texto = "" for page in reader.pages: texto += page.extract_text() or "" if len(texto) >= MAX_CHARS: break texto = texto.strip() if not texto: return None # Limpiar saltos de línea múltiples import re texto = re.sub(r'\n{3,}', '\n\n', texto) if len(texto) > MAX_CHARS: texto = texto[:MAX_CHARS] + "..." return texto except Exception: return None ``` ### Integración en el flujo de notificaciones En `poller.py`, método `revisar_ahora()`, cuando se detecta una notificación nueva y se va a enviar al abogado: 1. Intentar descargar el PDF (ya existe `descargar_documento_principal()`) 2. Si descarga OK → extraer extracto con `extraer_extracto()` 3. Enviar notificación con extracto incluido 4. Si descarga falla o PDF sin texto → enviar notificación sin extracto (degradación elegante) ### Formato del mensaje con extracto ``` 📋 Nueva notificación judicial {caratula} Exp. {numero}/{anio} — {juzgado} Tipo: {tipo} Fecha: {fecha} 📄 Extracto del documento: {extracto} ⚠️ Este es el texto crudo del documento. Verificá el original antes de actuar. [Ver PDF] [Ir al sistema del PJ] ``` Si no hay extracto disponible, el mensaje es igual pero sin la sección "📄 Extracto del documento". ### En `cmd_pdf()` de `telegram_bot.py` Cuando el abogado pide `/pdf 198/2026`, después de enviar el archivo también enviar el extracto como mensaje de texto separado: ``` 📄 Contenido del documento: {extracto} ⚠️ Verificá el original antes de actuar. ``` ### Verificación - [ ] Una notificación nueva llega con el extracto incluido - [ ] Si el PDF no tiene texto extraíble, la notificación llega igual sin extracto - [ ] El extracto no supera 600 caracteres - [ ] El disclaimer siempre aparece cuando hay extracto - [ ] Los bytes del PDF nunca se escriben a disco - [ ] `scrub()` en cualquier log que mencione el contenido del extracto - [ ] Agregar `pypdf` a `pyproject.toml` --- ## Feature 1 — Backend de administración web (FastAPI + HTML puro) ### Contexto Panel de administración interno para gestionar tenants y ver actividad. Solo para el admin (Marcos). No es un producto — es una herramienta operativa. HTML puro servido por FastAPI. Sin framework frontend. Funcional sobre bonito. ### Nueva dependencia Agregar a `pyproject.toml`: ``` "fastapi>=0.115", "uvicorn>=0.32", "jinja2>=3.1", ``` ### Estructura de archivos nuevos ``` admin/ __init__.py app.py ← FastAPI app del panel auth.py ← autenticación básica (token en .env) templates/ base.html ← layout común tenants.html ← listado de tenants tenant.html ← detalle de un tenant audit.html ← audit log ``` ### Autenticación del panel Token estático en `.env`: ``` ADMIN_TOKEN=genera-uno-con-secrets.token_hex(32) ``` Middleware simple: si el request no tiene el header `X-Admin-Token` con el valor correcto → 403. No hace falta sesiones ni cookies para este sprint. Agregar a `Settings` en `config.py`: ```python admin_token: str = "" admin_port: int = 8080 ``` ### Rutas del panel ``` GET / → redirect a /tenants GET /tenants → listado de todos los tenants GET /tenants/{id} → detalle de un tenant POST /tenants/{id}/resetear → resetea el tenant (borra DB + snapshot) GET /audit → audit log, últimas 200 entradas, filtrable por chat_id GET /audit?chat_id=X → audit log filtrado por tenant ``` ### Página /tenants — tabla con columnas | chat_id | nombre | estado | tono | timezone | cédula (primeros 3 dígitos + ***) | último chequeo | acciones | |---------|--------|--------|------|----------|-----------------------------------|----------------|---------| La cédula se muestra parcialmente — nunca en texto plano. "Acciones" tiene un botón "Resetear" que hace POST a `/tenants/{id}/resetear`. ### Página /tenants/{id} — detalle - Todos los campos del tenant - Últimas 20 entradas del audit_log para ese chat_id - Botón resetear ### Página /audit — tabla con columnas | timestamp (hora local) | chat_id | nombre tenant | evento | resultado | detalle (JSON colapsado) | Filtro por chat_id con un input de texto simple. Paginación básica: 200 registros por página, botón "más antiguos". ### Arrancar el panel junto con el bot En `main.py`, arrancar el servidor de administración en un thread separado: ```python import uvicorn import threading def run_admin(): uvicorn.run("admin.app:app", host="127.0.0.1", port=settings.admin_port, log_level="warning") admin_thread = threading.Thread(target=run_admin, daemon=True) admin_thread.start() ``` El panel corre en `127.0.0.1:8080` — solo accesible via SSH tunnel, no expuesto públicamente. Para acceder desde tu Mac: ```bash ssh -L 8080:127.0.0.1:8080 pedrito@ # Luego abrir http://localhost:8080 en el browser ``` ### Seguridad del panel - Solo escucha en 127.0.0.1 (no en 0.0.0.0) — inaccesible desde internet - Requiere el token en cada request - Las cédulas nunca se muestran completas - Los bearer tokens del PJ nunca aparecen en el panel - El panel es de solo lectura excepto la acción "resetear" ### Verificación - [ ] `GET /tenants` muestra los tenants registrados con cédula parcial - [ ] `POST /tenants/{id}/resetear` borra el tenant y su snapshot - [ ] `GET /audit` muestra las últimas 200 entradas - [ ] `GET /audit?chat_id=X` filtra correctamente - [ ] Sin `ADMIN_TOKEN` en el request → 403 - [ ] El panel NO es accesible desde internet (solo 127.0.0.1) - [ ] El panel arranca junto con el bot sin errores - [ ] Agregar `fastapi`, `uvicorn`, `jinja2` a `pyproject.toml` --- ## Orden de implementación sugerido 1. Feature 5 (frases libres) — 2-3 horas, sin dependencias nuevas 2. Feature 3 (extracto PDF) — 3-4 horas, una dependencia nueva 3. Feature 1 (panel admin) — 4-6 horas, más compleja ## Lo que NO hacer en este sprint - No usar LLM para nada — ni resumen, ni clasificación, ni mapeo de intenciones - No exponer el panel en un puerto público - No guardar PDFs en disco - No mostrar cédulas completas en el panel - No cambiar el onboarding - No cambiar el flujo de notificaciones existente más allá de agregar el extracto