feat: F3 — extracto de PDF en notificaciones y comando /pdf (pypdf, sin disco)
This commit is contained in:
43
pdf_extractor.py
Normal file
43
pdf_extractor.py
Normal 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
|
||||
28
poller.py
28
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]
|
||||
|
||||
@@ -15,6 +15,7 @@ dependencies = [
|
||||
"python-dotenv>=1.0",
|
||||
"aiosqlite>=0.20",
|
||||
"aiohttp>=3.9",
|
||||
"pypdf>=4.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -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
11
uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user