From 61a15c5c501e7f9fa5c47c464296ba8c21684c92 Mon Sep 17 00:00:00 2001 From: markosbenitez Date: Sat, 30 May 2026 01:13:31 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20F3=20=E2=80=94=20extracto=20de=20PDF=20?= =?UTF-8?q?en=20notificaciones=20y=20comando=20/pdf=20(pypdf,=20sin=20disc?= =?UTF-8?q?o)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pdf_extractor.py | 43 +++++++++++++++++++++++++++++++++++++++++++ poller.py | 28 +++++++++++++++++++++++++++- pyproject.toml | 1 + telegram_bot.py | 33 +++++++++++++++++++++++++++++++-- uv.lock | 11 +++++++++++ 5 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 pdf_extractor.py diff --git a/pdf_extractor.py b/pdf_extractor.py new file mode 100644 index 0000000..dd768dc --- /dev/null +++ b/pdf_extractor.py @@ -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 diff --git a/poller.py b/poller.py index 868e26a..1b145a2 100644 --- a/poller.py +++ b/poller.py @@ -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] diff --git a/pyproject.toml b/pyproject.toml index a05d2b8..290e6a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "python-dotenv>=1.0", "aiosqlite>=0.20", "aiohttp>=3.9", + "pypdf>=4.0", ] [dependency-groups] diff --git a/telegram_bot.py b/telegram_bot.py index 9d420c0..29a1de3 100644 --- a/telegram_bot.py +++ b/telegram_bot.py @@ -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\\." ) diff --git a/uv.lock b/uv.lock index db1f018..2e100c7 100644 --- a/uv.lock +++ b/uv.lock @@ -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"