12 Commits

Author SHA1 Message Date
markosbenitez
b3a644669e fix de /pdf 2026-06-19 18:17:55 -03:00
markosbenitez
76a5e58477 fix: listar_actuaciones usa ':' como separador en URL — corrige 404 en /pdf y /exp 2026-06-19 18:10:19 -03:00
markosbenitez
816296a7a0 admin/app.py — FastAPI con rutas /tenants, /tenants/{id}, /tenants/{id}/resetear, /audit
admin/auth.py — dependencia X-Admin-Token con secrets.compare_digest
4 templates HTML — funcionales, sin framework frontend
main.py — arranca admin en thread daemon; si ADMIN_TOKEN está vacío, no inicia el panel
Puerto 9090 (separado de health en 8080)
Cédula nunca expuesta — aparece como ***
2026-05-30 01:40:57 -03:00
markosbenitez
8bd1a0dd5b feat: F1 — panel admin web (FastAPI, solo 127.0.0.1, X-Admin-Token, SSH tunnel) 2026-05-30 01:37:14 -03:00
markosbenitez
61a15c5c50 feat: F3 — extracto de PDF en notificaciones y comando /pdf (pypdf, sin disco) 2026-05-30 01:13:31 -03:00
markosbenitez
64cfc4d1b7 poller.py: la revisión al arrancar ahora respeta _en_horario_habitual() — si son las 06:18 PYT no revisa hasta las 07:00
health.py: el watcher calcula la hora actual en America/Asuncion antes de alertar — fuera de horario hábil (noches, fines de semana) no manda alerta aunque el último poll sea de hace horas
2026-05-30 01:07:02 -03:00
markosbenitez
37b6b82016 fix: revisión inicial respeta horario; health alert solo en horario hábil PYT 2026-05-29 10:35:14 -03:00
markosbenitez
a868eb5b69 fix: chown /app completo + entrypoint directo al venv para evitar permisos con uv 2026-05-28 23:17:09 -03:00
markosbenitez
2957a6b4fd pusheado manual 2026-05-28 22:57:51 -03:00
markosbenitez
4be7bf13c3 fix: UV_NO_CACHE=1 para evitar error de permisos al arrancar como UID 1000 2026-05-28 22:52:17 -03:00
markosbenitez
ec751e5414 docs: DEPLOY.md adaptado al estado real del servidor
Versión para VPS donde Docker, Nginx y usuario pedrito ya existen.
Elimina: instalación de Docker, Nginx, creación de usuario, configuración DNS.
Agrega: tabla de estado inicial, nota sobre docker-compose v1 vs v2.
De 20 secciones (60-90 min) a 17 secciones (30-45 min).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 19:48:29 -03:00
markosbenitez
9c60972bcd prompt sprint 4 2026-05-28 01:12:25 -03:00
23 changed files with 1262 additions and 761 deletions

15
.claude/settings.json Normal file
View File

@@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(git add *)",
"Bash(git commit -m ' *)",
"Bash(git checkout *)",
"Bash(git commit *)",
"Bash(git merge *)",
"Bash(git remote *)",
"Bash(uv sync *)",
"Bash(python -c \"from pdf_extractor import extraer_extracto; print\\('ok'\\)\")",
"Bash(python -c \"import poller; import telegram_bot; print\\('ok'\\)\")"
]
}
}

View File

@@ -33,6 +33,12 @@ TELEGRAM_EXTRA_IDS=
# POLL_HORA_INICIO=7 # POLL_HORA_INICIO=7
# POLL_HORA_FIN=18 # POLL_HORA_FIN=18
# ─── Panel de administración ──────────────────────────────────────────────────
# Generar con: python -c "import secrets; print(secrets.token_hex(32))"
# Acceso via SSH tunnel: ssh -L 9090:127.0.0.1:9090 pedrito@<IP>
# Luego abrir http://localhost:9090 con header X-Admin-Token
ADMIN_TOKEN=
# ─── Otros (no tocar salvo que sepas lo que hacés) ────────────────────────────── # ─── Otros (no tocar salvo que sepas lo que hacés) ──────────────────────────────
# LOG_LEVEL=INFO # LOG_LEVEL=INFO
# DATA_DIR=./data # DATA_DIR=./data

978
DEPLOY.md

File diff suppressed because it is too large Load Diff

View File

@@ -15,10 +15,10 @@ COPY . .
RUN uv sync --frozen RUN uv sync --frozen
# Directorio de datos persistente (montar como volumen) # Directorio de datos persistente (montar como volumen)
RUN mkdir -p /app/data && chown -R 1000:1000 /app/data RUN mkdir -p /app/data && chown -R 1000:1000 /app
USER 1000 USER 1000
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["uv", "run", "python", "main.py"] ENTRYPOINT [".venv/bin/python", "main.py"]

View File

@@ -23,6 +23,15 @@ para ver la estructura exacta del JSON y ajustar el parsing en `csj_client.py`.
--- ---
### Convención de URL en /actuaciones — corregido 19/06/2026
`listar_actuaciones()` usaba separador "/" entre codCasoJudicial y origen.
El PJ espera ":" para este endpoint (igual que en descargar_documento_principal()).
Bug detectado por beta tester via /pdf, confirmado en logs de producción.
Afectaba: /pdf y /exp (ambos llaman listar_actuaciones()).
---
## Bot de Telegram ## Bot de Telegram
*(agregar hallazgos acá)* *(agregar hallazgos acá)*

View File

@@ -0,0 +1,319 @@
# 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@<IP_VPS>
# 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

0
admin/__init__.py Normal file
View File

159
admin/app.py Normal file
View File

@@ -0,0 +1,159 @@
"""
admin/app.py — Panel de administración interno (FastAPI).
Solo escucha en 127.0.0.1. Acceso via SSH tunnel:
ssh -L 9090:127.0.0.1:9090 pedrito@<IP_VPS>
Luego: http://localhost:9090
Autenticación: header X-Admin-Token en cada request.
"""
from __future__ import annotations
import json
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
import aiosqlite
from fastapi import Depends, FastAPI, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from admin.auth import require_token
_db_path: str = ""
_admin_token: str = ""
_data_dir: Path = Path("./data")
templates = Jinja2Templates(directory="admin/templates")
@asynccontextmanager
async def _lifespan(app: FastAPI):
app.state.db = await aiosqlite.connect(_db_path)
app.state.db.row_factory = aiosqlite.Row
yield
await app.state.db.close()
def create_app(db_path: str, admin_token: str, data_dir: Path) -> FastAPI:
global _db_path, _admin_token, _data_dir
_db_path = db_path
_admin_token = admin_token
_data_dir = data_dir
app = FastAPI(lifespan=_lifespan, docs_url=None, redoc_url=None)
auth = Depends(require_token(admin_token))
# ── helpers ───────────────────────────────────────────────────────────────
def _mask_cedula(credentials_enc: str | None) -> str:
"""Muestra '***' — la cédula está cifrada y no se expone en el panel."""
if not credentials_enc:
return ""
return "***"
def _parse_detalle(detalle_json: str | None) -> str:
if not detalle_json:
return ""
try:
return json.dumps(json.loads(detalle_json), ensure_ascii=False, indent=None)
except Exception:
return detalle_json or ""
# ── rutas ─────────────────────────────────────────────────────────────────
@app.get("/", include_in_schema=False)
async def root():
return RedirectResponse(url="/tenants")
@app.get("/tenants", response_class=HTMLResponse, dependencies=[auth])
async def lista_tenants(request: Request):
db: aiosqlite.Connection = request.app.state.db
async with db.execute("SELECT * FROM tenants ORDER BY created_at DESC") as cur:
rows = [dict(r) for r in await cur.fetchall()]
for r in rows:
r["cedula_masked"] = _mask_cedula(r.get("credentials_enc"))
r.pop("credentials_enc", None)
return templates.TemplateResponse("tenants.html", {"request": request, "tenants": rows})
@app.get("/tenants/{chat_id}", response_class=HTMLResponse, dependencies=[auth])
async def detalle_tenant(request: Request, chat_id: int):
db: aiosqlite.Connection = request.app.state.db
async with db.execute("SELECT * FROM tenants WHERE chat_id = ?", (chat_id,)) as cur:
row = await cur.fetchone()
if not row:
return HTMLResponse("<h1>Tenant no encontrado</h1>", status_code=404)
tenant: dict[str, Any] = dict(row)
tenant["cedula_masked"] = _mask_cedula(tenant.get("credentials_enc"))
tenant.pop("credentials_enc", None)
async with db.execute(
"SELECT * FROM audit_log WHERE chat_id = ? ORDER BY timestamp DESC LIMIT 20",
(chat_id,),
) as cur:
logs = [dict(r) for r in await cur.fetchall()]
for entry in logs:
entry["detalle_str"] = _parse_detalle(entry.get("detalle"))
return templates.TemplateResponse(
"tenant.html", {"request": request, "tenant": tenant, "logs": logs}
)
@app.post("/tenants/{chat_id}/resetear", dependencies=[auth])
async def resetear_tenant(request: Request, chat_id: int):
db: aiosqlite.Connection = request.app.state.db
await db.execute("DELETE FROM tenants WHERE chat_id = ?", (chat_id,))
await db.execute("DELETE FROM audit_log WHERE chat_id = ?", (chat_id,))
await db.commit()
snapshot = _data_dir / f"snapshot_{chat_id}.json"
if snapshot.exists():
snapshot.unlink()
return RedirectResponse(url="/tenants", status_code=303)
@app.get("/audit", response_class=HTMLResponse, dependencies=[auth])
async def audit_log(request: Request, chat_id: str = "", page: int = 1):
db: aiosqlite.Connection = request.app.state.db
limit = 200
offset = (page - 1) * limit
if chat_id.strip():
try:
cid = int(chat_id.strip())
async with db.execute(
"SELECT * FROM audit_log WHERE chat_id = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
(cid, limit, offset),
) as cur:
logs = [dict(r) for r in await cur.fetchall()]
async with db.execute(
"SELECT COUNT(*) FROM audit_log WHERE chat_id = ?", (cid,)
) as cur:
total = (await cur.fetchone())[0]
except ValueError:
logs, total = [], 0
else:
async with db.execute(
"SELECT * FROM audit_log ORDER BY timestamp DESC LIMIT ? OFFSET ?",
(limit, offset),
) as cur:
logs = [dict(r) for r in await cur.fetchall()]
async with db.execute("SELECT COUNT(*) FROM audit_log") as cur:
total = (await cur.fetchone())[0]
for entry in logs:
entry["detalle_str"] = _parse_detalle(entry.get("detalle"))
has_more = (offset + limit) < total
return templates.TemplateResponse(
"audit.html",
{
"request": request,
"logs": logs,
"chat_id_filter": chat_id,
"page": page,
"has_more": has_more,
"total": total,
},
)
return app

22
admin/auth.py Normal file
View File

@@ -0,0 +1,22 @@
"""
admin/auth.py — Autenticación del panel de administración.
Token estático via header X-Admin-Token.
Si ADMIN_TOKEN no está configurado, el panel rechaza todos los requests.
"""
from __future__ import annotations
import secrets
from fastapi import HTTPException, Request
def require_token(token_esperado: str):
"""Devuelve una dependencia FastAPI que valida X-Admin-Token."""
async def _check(request: Request) -> None:
if not token_esperado:
raise HTTPException(status_code=403, detail="Panel no configurado: falta ADMIN_TOKEN")
token = request.headers.get("X-Admin-Token", "")
if not secrets.compare_digest(token, token_esperado):
raise HTTPException(status_code=403, detail="Token inválido")
return _check

View File

@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %} — Audit log{% endblock %}
{% block content %}
<h1>Audit log ({{ total }} registros)</h1>
<form class="filter-bar" method="get" action="/audit">
<input type="text" name="chat_id" value="{{ chat_id_filter }}" placeholder="Filtrar por chat_id">
<button type="submit" class="btn btn-primary">Filtrar</button>
{% if chat_id_filter %}<a href="/audit" class="btn">Limpiar</a>{% endif %}
</form>
<table>
<thead>
<tr>
<th>Timestamp (UTC)</th>
<th>chat_id</th>
<th>Evento</th>
<th>Resultado</th>
<th>Detalle</th>
</tr>
</thead>
<tbody>
{% for entry in logs %}
<tr>
<td style="white-space:nowrap">{{ (entry.timestamp or "")[:19] }}</td>
<td>
{% if entry.chat_id %}
<a href="/tenants/{{ entry.chat_id }}">{{ entry.chat_id }}</a>
{% else %}—{% endif %}
</td>
<td><code>{{ entry.evento }}</code></td>
<td>{{ entry.resultado or "—" }}</td>
<td style="max-width:350px;word-break:break-all"><code>{{ entry.detalle_str }}</code></td>
</tr>
{% else %}
<tr><td colspan="5" style="text-align:center;color:#999">Sin registros</td></tr>
{% endfor %}
</tbody>
</table>
<div class="pagination">
{% if page > 1 %}
<a href="/audit?chat_id={{ chat_id_filter }}&page={{ page - 1 }}" class="btn">← Más recientes</a>
{% endif %}
<span>Página {{ page }}</span>
{% if has_more %}
<a href="/audit?chat_id={{ chat_id_filter }}&page={{ page + 1 }}" class="btn">Más antiguos →</a>
{% endif %}
</div>
{% endblock %}

48
admin/templates/base.html Normal file
View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pedrito Admin{% block title %}{% endblock %}</title>
<style>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; background: #f5f5f5; color: #222; }
nav { background: #1a1a2e; color: #fff; padding: 0.75rem 1.5rem; display: flex; align-items: center; gap: 1.5rem; }
nav strong { font-size: 1.1rem; color: #e2e2ff; }
nav a { color: #adb5ff; text-decoration: none; font-size: 0.95rem; }
nav a:hover { color: #fff; }
main { max-width: 1100px; margin: 1.5rem auto; padding: 0 1rem; }
h1 { font-size: 1.4rem; margin-bottom: 1rem; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
th { background: #e8e8f0; text-align: left; padding: 0.6rem 0.8rem; font-size: 0.85rem; color: #555; }
td { padding: 0.55rem 0.8rem; border-top: 1px solid #eee; font-size: 0.9rem; vertical-align: top; }
tr:hover td { background: #fafafa; }
.badge { display: inline-block; padding: 0.2rem 0.5rem; border-radius: 3px; font-size: 0.8rem; }
.badge-activo { background: #d4edda; color: #155724; }
.badge-pendiente { background: #fff3cd; color: #856404; }
.badge-suspendido { background: #f8d7da; color: #721c24; }
.btn { display: inline-block; padding: 0.35rem 0.75rem; border-radius: 4px; font-size: 0.85rem; text-decoration: none; border: none; cursor: pointer; }
.btn-primary { background: #4a6cf7; color: #fff; }
.btn-danger { background: #dc3545; color: #fff; }
.btn-sm { padding: 0.2rem 0.5rem; font-size: 0.8rem; }
form { display: inline; }
code { font-size: 0.8rem; background: #f0f0f0; padding: 1px 4px; border-radius: 3px; }
.detail-grid { display: grid; grid-template-columns: 180px 1fr; gap: 0.4rem 1rem; background: #fff; padding: 1rem 1.2rem; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,.1); margin-bottom: 1.5rem; }
.detail-grid dt { font-weight: 600; font-size: 0.85rem; color: #666; }
.detail-grid dd { margin: 0; font-size: 0.9rem; }
.filter-bar { display: flex; gap: 0.5rem; margin-bottom: 1rem; align-items: center; }
.filter-bar input { padding: 0.4rem 0.7rem; border: 1px solid #ccc; border-radius: 4px; font-size: 0.9rem; }
.pagination { margin-top: 1rem; display: flex; gap: 0.5rem; align-items: center; font-size: 0.9rem; }
</style>
</head>
<body>
<nav>
<strong>⚖️ Pedrito Admin</strong>
<a href="/tenants">Tenants</a>
<a href="/audit">Audit log</a>
</nav>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %} — Tenant {{ tenant.chat_id }}{% endblock %}
{% block content %}
<h1>Tenant {{ tenant.chat_id }}</h1>
<dl class="detail-grid">
<dt>chat_id</dt> <dd>{{ tenant.chat_id }}</dd>
<dt>Nombre</dt> <dd>{{ tenant.nombre_preferido or "—" }}</dd>
<dt>Estado</dt> <dd><span class="badge badge-{{ tenant.estado or 'pendiente' }}">{{ tenant.estado or "pendiente" }}</span></dd>
<dt>Tono</dt> <dd>{{ tenant.tono or "—" }}</dd>
<dt>Timezone</dt> <dd>{{ tenant.timezone or "—" }}</dd>
<dt>Cédula</dt> <dd><code>{{ tenant.cedula_masked }}</code></dd>
<dt>Creado</dt> <dd>{{ (tenant.created_at or "")[:19] }}</dd>
<dt>Último activo</dt><dd>{{ (tenant.last_active or "")[:19] }}</dd>
</dl>
<form method="post" action="/tenants/{{ tenant.chat_id }}/resetear"
onsubmit="return confirm('¿Resetear tenant {{ tenant.chat_id }}? Esta acción borra el tenant y su snapshot.')">
<button type="submit" class="btn btn-danger">Resetear tenant</button>
</form>
<h2 style="margin-top:2rem;font-size:1.1rem">Últimas 20 entradas del audit log</h2>
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Evento</th>
<th>Resultado</th>
<th>Detalle</th>
</tr>
</thead>
<tbody>
{% for entry in logs %}
<tr>
<td style="white-space:nowrap">{{ (entry.timestamp or "")[:19] }}</td>
<td><code>{{ entry.evento }}</code></td>
<td>{{ entry.resultado or "—" }}</td>
<td style="max-width:400px;word-break:break-all"><code>{{ entry.detalle_str }}</code></td>
</tr>
{% else %}
<tr><td colspan="4" style="text-align:center;color:#999">Sin registros</td></tr>
{% endfor %}
</tbody>
</table>
<p style="margin-top:1rem"><a href="/tenants">← Volver a tenants</a></p>
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %} — Tenants{% endblock %}
{% block content %}
<h1>Tenants ({{ tenants|length }})</h1>
<table>
<thead>
<tr>
<th>chat_id</th>
<th>Nombre</th>
<th>Estado</th>
<th>Tono</th>
<th>Timezone</th>
<th>Cédula</th>
<th>Último activo</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{% for t in tenants %}
<tr>
<td><a href="/tenants/{{ t.chat_id }}">{{ t.chat_id }}</a></td>
<td>{{ t.nombre_preferido or "—" }}</td>
<td><span class="badge badge-{{ t.estado or 'pendiente' }}">{{ t.estado or "pendiente" }}</span></td>
<td>{{ t.tono or "—" }}</td>
<td>{{ t.timezone or "—" }}</td>
<td><code>{{ t.cedula_masked }}</code></td>
<td>{{ (t.last_active or "")[:19] }}</td>
<td>
<a href="/tenants/{{ t.chat_id }}" class="btn btn-primary btn-sm">Ver</a>
<form method="post" action="/tenants/{{ t.chat_id }}/resetear"
onsubmit="return confirm('¿Resetear tenant {{ t.chat_id }}? Esta acción borra el tenant y su snapshot.')">
<button type="submit" class="btn btn-danger btn-sm">Resetear</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="8" style="text-align:center;color:#999">Sin tenants registrados</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -69,6 +69,10 @@ class Settings(BaseSettings):
health_port: int = 8080 health_port: int = 8080
health_alert_hours: int = 2 # alerta al admin si el poller no actualizó en N horas health_alert_hours: int = 2 # alerta al admin si el poller no actualizó en N horas
# Panel de administración (solo 127.0.0.1, acceso via SSH tunnel)
admin_token: str = "" # generar con: python -c "import secrets; print(secrets.token_hex(32))"
admin_port: int = 9090 # health_port usa 8080; admin usa puerto separado
# Logging # Logging
log_level: str = "INFO" log_level: str = "INFO"

View File

@@ -597,15 +597,16 @@ class CSJClient:
""" """
Retorna las actuaciones de un expediente. Retorna las actuaciones de un expediente.
URL confirmada por reconocimiento 22/05/2026: URL confirmada en logs de producción 19/06/2026:
GET /api/gestion-partes/CasoJudicial/{codCasoJudicial}/{origen}/0/actuaciones GET /api/gestion-partes/CasoJudicial/{codCasoJudicial}/{origen}:0/actuaciones
El segundo posicional (/0/) es fijo en todos los casos observados. El PJ usa ":" como separador entre cod_caso_judicial y origen
(igual que en descargar_documento_principal()), no "/".
""" """
session = await self._asegurar_sesion() session = await self._asegurar_sesion()
assert self._http is not None assert self._http is not None
url = f"/api/gestion-partes/CasoJudicial/{cod_caso_judicial}/{origen}/0/actuaciones" url = f"/api/gestion-partes/CasoJudicial/{cod_caso_judicial}/{origen}:0/actuaciones"
params: dict[str, Any] = { params: dict[str, Any] = {
"offset": offset, "offset": offset,
"cantidad": cantidad, "cantidad": cantidad,

View File

@@ -25,6 +25,9 @@ import hashlib
import json import json
import secrets import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
_PY_TZ = ZoneInfo("America/Asuncion")
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import aiohttp.web import aiohttp.web
@@ -231,6 +234,13 @@ class HealthServer:
await asyncio.sleep(300) await asyncio.sleep(300)
if _last_poll_at is None or _alert_sent: if _last_poll_at is None or _alert_sent:
continue continue
ahora_py = datetime.now(_PY_TZ)
en_horario = (
ahora_py.weekday() < 5
and self._settings.poll_hora_inicio <= ahora_py.hour < self._settings.poll_hora_fin
)
if not en_horario:
continue
delta = datetime.now(timezone.utc) - _last_poll_at delta = datetime.now(timezone.utc) - _last_poll_at
if delta <= timedelta(hours=self._settings.health_alert_hours): if delta <= timedelta(hours=self._settings.health_alert_hours):
continue continue

28
main.py
View File

@@ -10,6 +10,7 @@ import asyncio
import os import os
import signal import signal
import sys import sys
import threading
import structlog import structlog
from telegram.ext import Application from telegram.ext import Application
@@ -92,11 +93,34 @@ def _limpiar_env_sensibles() -> None:
"WEBHOOK_SECRET", "WEBHOOK_SECRET",
"HEALTH_AUTH_TOKEN", "HEALTH_AUTH_TOKEN",
"INVITE_CODE", "INVITE_CODE",
"ADMIN_TOKEN",
) )
for var in _sensibles: for var in _sensibles:
os.environ.pop(var, None) os.environ.pop(var, None)
def _run_admin(settings) -> None:
"""Arranca el panel de administración en su propio thread y event loop."""
import uvicorn
from admin.app import create_app
if not settings.admin_token:
logger.warning("admin_panel_sin_token_no_iniciado")
return
admin_app = create_app(
db_path=str(settings.db_path),
admin_token=settings.admin_token,
data_dir=settings.data_dir,
)
uvicorn.run(
admin_app,
host="127.0.0.1",
port=settings.admin_port,
log_level="warning",
)
async def main() -> None: async def main() -> None:
settings = get_settings() settings = get_settings()
_limpiar_env_sensibles() _limpiar_env_sensibles()
@@ -133,6 +157,10 @@ async def main() -> None:
lambda chat_id: poller.revisar_ahora(chat_id=chat_id, es_manual=True) lambda chat_id: poller.revisar_ahora(chat_id=chat_id, es_manual=True)
) )
# Panel de administración (thread daemon — muere con el proceso principal)
admin_thread = threading.Thread(target=_run_admin, args=(settings,), daemon=True)
admin_thread.start()
# Manejo de señales para shutdown limpio # Manejo de señales para shutdown limpio
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
health_server: HealthServer | None = None health_server: HealthServer | None = None

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, CSJAuthError,
CSJClient, CSJClient,
CSJConnectionError, CSJConnectionError,
CSJDocumentoNoDisponibleError,
CSJPasswordTemporalError, CSJPasswordTemporalError,
) )
from pdf_extractor import extraer_extracto
from database import delete_tenant, get_tenant, list_active_tenants, save_tenant from database import delete_tenant, get_tenant, list_active_tenants, save_tenant
from storage import Snapshot from storage import Snapshot
@@ -66,6 +68,29 @@ class Poller:
self._snapshots[chat_id] = Snapshot(path) self._snapshots[chat_id] = Snapshot(path)
return self._snapshots[chat_id] 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 # Lógica de revisión por tenant
# ────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────
@@ -117,7 +142,8 @@ class Poller:
if nuevas: if nuevas:
for notif in 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) snapshot.marcar_vista(notif.cod_notificacion_origen)
elif es_manual: elif es_manual:
await self._bot.enviar_sin_novedades(tenant, snapshot) # type: ignore[attr-defined] await self._bot.enviar_sin_novedades(tenant, snapshot) # type: ignore[attr-defined]
@@ -205,10 +231,13 @@ class Poller:
hora_fin=self._settings.poll_hora_fin, hora_fin=self._settings.poll_hora_fin,
) )
logger.info("revision_inicial_al_arrancar") if self._en_horario_habitual():
count = await self.revisar_ahora() logger.info("revision_inicial_al_arrancar")
if self._on_poll_done: count = await self.revisar_ahora()
self._on_poll_done(count) if self._on_poll_done:
self._on_poll_done(count)
else:
logger.info("revision_inicial_skipped_fuera_de_horario")
while self._running: while self._running:
try: try:

BIN
pruebas_beta_pedrito.docx Normal file

Binary file not shown.

View File

@@ -15,6 +15,10 @@ dependencies = [
"python-dotenv>=1.0", "python-dotenv>=1.0",
"aiosqlite>=0.20", "aiosqlite>=0.20",
"aiohttp>=3.9", "aiohttp>=3.9",
"pypdf>=4.0",
"fastapi>=0.115",
"uvicorn>=0.32",
"jinja2>=3.1",
] ]
[dependency-groups] [dependency-groups]

View File

@@ -39,6 +39,7 @@ from csj_client import (
) )
from database import get_tenant from database import get_tenant
from onboarding import OnboardingFlow from onboarding import OnboardingFlow
from pdf_extractor import extraer_extracto
from storage import Snapshot from storage import Snapshot
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -647,6 +648,13 @@ class PedritoBot:
filename=filename, filename=filename,
caption=caption, 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) 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, document=pdf_bytes,
filename=f"Actuacion_{cod_caso}_{cod_actuacion}.pdf", 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)) 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) # 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.""" """Envía una notificación nueva al tenant específico, con su tono."""
assert self._app is not None assert self._app is not None
chat_id: int = tenant["chat_id"] chat_id: int = tenant["chat_id"]
@@ -804,13 +824,22 @@ class PedritoBot:
nombre=nombre, exp=notif.expediente_str 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 = ( texto = (
f"{_escape_md(intro)}\n\n" f"{_escape_md(intro)}\n\n"
f"📋 *Nueva notificación judicial*\n\n" f"📋 *Nueva notificación judicial*\n\n"
f"{_escape_md(caratula)}\n" f"{_escape_md(caratula)}\n"
f"Exp\\. {_escape_md(notif.expediente_str)} \\{_escape_md(juzgado)}\n\n" f"Exp\\. {_escape_md(notif.expediente_str)} \\{_escape_md(juzgado)}\n\n"
f"Tipo: {_escape_md(tipo)}\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\\." f"⚠️ Para acusar esta notificación entrá directamente en el sistema del PJ\\."
) )

155
uv.lock generated
View File

@@ -132,6 +132,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
] ]
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]] [[package]]
name = "annotated-types" name = "annotated-types"
version = "0.7.0" version = "0.7.0"
@@ -290,6 +299,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
] ]
[[package]]
name = "click"
version = "8.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
]
[[package]] [[package]]
name = "colorama" name = "colorama"
version = "0.4.6" version = "0.4.6"
@@ -352,6 +373,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
] ]
[[package]]
name = "fastapi"
version = "0.136.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" },
]
[[package]] [[package]]
name = "frozenlist" name = "frozenlist"
version = "1.8.0" version = "1.8.0"
@@ -496,6 +533,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
] ]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]] [[package]]
name = "librt" name = "librt"
version = "0.11.0" version = "0.11.0"
@@ -556,6 +605,69 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" },
] ]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]] [[package]]
name = "multidict" name = "multidict"
version = "6.7.1" version = "6.7.1"
@@ -735,12 +847,16 @@ dependencies = [
{ name = "aiosqlite" }, { name = "aiosqlite" },
{ name = "apscheduler" }, { name = "apscheduler" },
{ name = "cryptography" }, { name = "cryptography" },
{ name = "fastapi" },
{ name = "httpx" }, { name = "httpx" },
{ name = "jinja2" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pypdf" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "python-telegram-bot", extra = ["ext"] }, { name = "python-telegram-bot", extra = ["ext"] },
{ name = "structlog" }, { name = "structlog" },
{ name = "uvicorn" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@@ -758,12 +874,16 @@ requires-dist = [
{ name = "aiosqlite", specifier = ">=0.20" }, { name = "aiosqlite", specifier = ">=0.20" },
{ name = "apscheduler", specifier = ">=3.10" }, { name = "apscheduler", specifier = ">=3.10" },
{ name = "cryptography", specifier = ">=44.0" }, { name = "cryptography", specifier = ">=44.0" },
{ name = "fastapi", specifier = ">=0.115" },
{ name = "httpx", specifier = ">=0.28" }, { name = "httpx", specifier = ">=0.28" },
{ name = "jinja2", specifier = ">=3.1" },
{ name = "pydantic", specifier = ">=2.9" }, { name = "pydantic", specifier = ">=2.9" },
{ name = "pydantic-settings", specifier = ">=2.6" }, { name = "pydantic-settings", specifier = ">=2.6" },
{ name = "pypdf", specifier = ">=4.0" },
{ name = "python-dotenv", specifier = ">=1.0" }, { name = "python-dotenv", specifier = ">=1.0" },
{ name = "python-telegram-bot", extras = ["ext"], specifier = ">=21.9" }, { name = "python-telegram-bot", extras = ["ext"], specifier = ">=21.9" },
{ name = "structlog", specifier = ">=24.4" }, { name = "structlog", specifier = ">=24.4" },
{ name = "uvicorn", specifier = ">=0.32" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
@@ -1000,6 +1120,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" }, { 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]] [[package]]
name = "pytest" name = "pytest"
version = "9.0.3" version = "9.0.3"
@@ -1097,6 +1226,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" },
] ]
[[package]]
name = "starlette"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c5/bf/616a066c2760f6c2b1ae3437cc28149734d069fbb46511712beae118a68c/starlette-1.2.0.tar.gz", hash = "sha256:3c5a6b23fff42492914e93890bb80cbfea72dbf37de268eec06185d62a4ca553", size = 2668923, upload-time = "2026-05-28T11:42:50.568Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/85/492183764d5d01d4514be3730fdb8e228a80605783099551c51627578b5d/starlette-1.2.0-py3-none-any.whl", hash = "sha256:36e0c76ac59157e75dc4b3bdeafba97fb04eaf1878045f15dbef666a6f092ed7", size = 73213, upload-time = "2026-05-28T11:42:48.801Z" },
]
[[package]] [[package]]
name = "structlog" name = "structlog"
version = "25.5.0" version = "25.5.0"
@@ -1165,6 +1307,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
] ]
[[package]]
name = "uvicorn"
version = "0.48.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" },
]
[[package]] [[package]]
name = "yarl" name = "yarl"
version = "1.24.2" version = "1.24.2"