feat: F3 — extracto de PDF en notificaciones y comando /pdf (pypdf, sin disco)

This commit is contained in:
markosbenitez
2026-05-30 01:13:31 -03:00
parent 64cfc4d1b7
commit 61a15c5c50
5 changed files with 113 additions and 3 deletions

43
pdf_extractor.py Normal file
View File

@@ -0,0 +1,43 @@
"""
pdf_extractor.py — Extracción de texto de PDFs judiciales.
Sin LLM. Sin interpretación. Solo texto crudo.
Los PDFs nunca se guardan en disco — se procesan en memoria.
"""
from __future__ import annotations
import io
import re
from pypdf import PdfReader
MAX_CHARS = 600
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, error de parseo).
"""
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
texto = re.sub(r"\n{3,}", "\n\n", texto)
if len(texto) > MAX_CHARS:
texto = texto[:MAX_CHARS] + "..."
return texto
except Exception:
return None

View File

@@ -23,8 +23,10 @@ from csj_client import (
CSJAuthError,
CSJClient,
CSJConnectionError,
CSJDocumentoNoDisponibleError,
CSJPasswordTemporalError,
)
from pdf_extractor import extraer_extracto
from database import delete_tenant, get_tenant, list_active_tenants, save_tenant
from storage import Snapshot
@@ -66,6 +68,29 @@ class Poller:
self._snapshots[chat_id] = Snapshot(path)
return self._snapshots[chat_id]
async def _intentar_extracto(
self,
client: CSJClient,
notif: object,
) -> str | None:
"""Descarga el PDF de la notificación y extrae el texto. Degradación elegante: None si falla."""
from csj_client import NotificacionPendiente # evita import circular en type hint
assert isinstance(notif, NotificacionPendiente)
if not (notif.cod_caso_judicial and notif.cod_actuacion_caso and notif.origen is not None):
return None
try:
pdf_bytes = await client.descargar_documento_principal(
cod_caso_judicial=notif.cod_caso_judicial,
origen_caso=notif.origen,
cod_actuacion_caso=notif.cod_actuacion_caso,
origen_actuacion=notif.origen,
)
return extraer_extracto(pdf_bytes)
except (CSJDocumentoNoDisponibleError, CSJConnectionError, CSJAuthError):
return None
except Exception:
return None
# ──────────────────────────────────────────────────────────────────────────
# Lógica de revisión por tenant
# ──────────────────────────────────────────────────────────────────────────
@@ -117,7 +142,8 @@ class Poller:
if nuevas:
for notif in nuevas:
await self._bot.enviar_notificacion(notif, tenant) # type: ignore[attr-defined]
extracto = await self._intentar_extracto(client, notif)
await self._bot.enviar_notificacion(notif, tenant, extracto=extracto) # type: ignore[attr-defined]
snapshot.marcar_vista(notif.cod_notificacion_origen)
elif es_manual:
await self._bot.enviar_sin_novedades(tenant, snapshot) # type: ignore[attr-defined]

View File

@@ -15,6 +15,7 @@ dependencies = [
"python-dotenv>=1.0",
"aiosqlite>=0.20",
"aiohttp>=3.9",
"pypdf>=4.0",
]
[dependency-groups]

View File

@@ -39,6 +39,7 @@ from csj_client import (
)
from database import get_tenant
from onboarding import OnboardingFlow
from pdf_extractor import extraer_extracto
from storage import Snapshot
if TYPE_CHECKING:
@@ -647,6 +648,13 @@ class PedritoBot:
filename=filename,
caption=caption,
)
extracto = extraer_extracto(pdf_bytes)
if extracto:
await update.effective_message.reply_text( # type: ignore[union-attr]
f"📄 *Contenido del documento:*\n{_escape_md(extracto)}\n\n"
"⚠️ _Verificá el original antes de actuar\\._",
parse_mode=ParseMode.MARKDOWN_V2,
)
await audit.log("bot_pdf_enviado", chat_id=chat_id, detalle={"cod_caso": caso.cod_caso_judicial, "cod_actuacion": actuacion.cod_actuacion_caso}, tenant=tenant)
# ──────────────────────────────────────────────────────────────────────────
@@ -782,13 +790,25 @@ class PedritoBot:
document=pdf_bytes,
filename=f"Actuacion_{cod_caso}_{cod_actuacion}.pdf",
)
extracto = extraer_extracto(pdf_bytes)
if extracto:
await query.message.reply_text( # type: ignore[union-attr]
f"📄 *Contenido del documento:*\n{_escape_md(extracto)}\n\n"
"⚠️ _Verificá el original antes de actuar\\._",
parse_mode=ParseMode.MARKDOWN_V2,
)
await audit.log("bot_pdf_enviado", chat_id=chat_id, detalle={"cod_caso": int(cod_caso), "cod_actuacion": int(cod_actuacion)}, tenant=await get_tenant(chat_id))
# ──────────────────────────────────────────────────────────────────────────
# Mensajes proactivos (llamados desde el poller)
# ──────────────────────────────────────────────────────────────────────────
async def enviar_notificacion(self, notif: NotificacionPendiente, tenant: dict) -> None:
async def enviar_notificacion(
self,
notif: NotificacionPendiente,
tenant: dict,
extracto: str | None = None,
) -> None:
"""Envía una notificación nueva al tenant específico, con su tono."""
assert self._app is not None
chat_id: int = tenant["chat_id"]
@@ -804,13 +824,22 @@ class PedritoBot:
nombre=nombre, exp=notif.expediente_str
)
extracto_bloque = ""
if extracto:
extracto_bloque = (
f"\n\n📄 *Extracto del documento:*\n"
f"{_escape_md(extracto)}\n\n"
f"⚠️ _Este es el texto crudo del documento\\. Verificá el original antes de actuar\\._"
)
texto = (
f"{_escape_md(intro)}\n\n"
f"📋 *Nueva notificación judicial*\n\n"
f"{_escape_md(caratula)}\n"
f"Exp\\. {_escape_md(notif.expediente_str)} \\{_escape_md(juzgado)}\n\n"
f"Tipo: {_escape_md(tipo)}\n"
f"Fecha: {_escape_md(fecha)}\n\n"
f"Fecha: {_escape_md(fecha)}"
f"{extracto_bloque}\n\n"
f"⚠️ Para acusar esta notificación entrá directamente en el sistema del PJ\\."
)

11
uv.lock generated
View File

@@ -738,6 +738,7 @@ dependencies = [
{ name = "httpx" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pypdf" },
{ name = "python-dotenv" },
{ name = "python-telegram-bot", extra = ["ext"] },
{ name = "structlog" },
@@ -761,6 +762,7 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.28" },
{ name = "pydantic", specifier = ">=2.9" },
{ name = "pydantic-settings", specifier = ">=2.6" },
{ name = "pypdf", specifier = ">=4.0" },
{ name = "python-dotenv", specifier = ">=1.0" },
{ name = "python-telegram-bot", extras = ["ext"], specifier = ">=21.9" },
{ name = "structlog", specifier = ">=24.4" },
@@ -1000,6 +1002,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pypdf"
version = "6.12.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0a/6d/20879428577c1e57ecd41b69dc86beabf43db9287ad2e702207f8b48c751/pypdf-6.12.2.tar.gz", hash = "sha256:111669eb6680c04495ae0c113a1476e3bf93a95761d23c7406b591c80a6490b1", size = 6468184, upload-time = "2026-05-26T13:31:26.911Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/44/fee070a16639d9869bb6a7e0f3a1b3946da1d66f32b9260b4d19cb90d7b2/pypdf-6.12.2-py3-none-any.whl", hash = "sha256:67b2699357a1f3f4c945940ea80826349ee507c9e2577724a14b4941982c104d", size = 343865, upload-time = "2026-05-26T13:31:25.068Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"