Spaces:
Sleeping
Sleeping
Commit ·
7adf02c
0
Parent(s):
deploy inicial
Browse files- .gitignore +8 -0
- Dockerfile +54 -0
- Modelo_Pymes.pkl +0 -0
- README.md +141 -0
- app.py +126 -0
- assets/style.css +431 -0
- callbacks/__init__.py +0 -0
- callbacks/navegacion.py +421 -0
- data/__init__.py +0 -0
- data/definitions.py +272 -0
- docker-compose.yml +30 -0
- layouts/__init__.py +0 -0
- layouts/pages.py +268 -0
- logic/__init__.py +1 -0
- logic/extractor.py +267 -0
- logic/modelo.py +348 -0
- logic/venn.py +117 -0
- render.yaml +9 -0
- requirements.txt +0 -0
.gitignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
venv/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
.env
|
| 5 |
+
*.egg-info/
|
| 6 |
+
dist/
|
| 7 |
+
build/
|
| 8 |
+
.DS_Store
|
Dockerfile
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ── AIM Dashboard — Dockerfile ──────────────────────────────────────────────
|
| 2 |
+
# Imagen base: Python 3.11 slim para menor tamaño
|
| 3 |
+
FROM python:3.11-slim
|
| 4 |
+
|
| 5 |
+
# Metadatos
|
| 6 |
+
LABEL maintainer="tu-equipo@empresa.cl"
|
| 7 |
+
LABEL description="AIM Dashboard — Perfil de ciberseguridad para PyMEs"
|
| 8 |
+
|
| 9 |
+
# Variables de entorno
|
| 10 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 11 |
+
PYTHONUNBUFFERED=1 \
|
| 12 |
+
PORT=8050
|
| 13 |
+
|
| 14 |
+
# Directorio de trabajo dentro del contenedor
|
| 15 |
+
WORKDIR /app
|
| 16 |
+
|
| 17 |
+
# Instalar dependencias del sistema (necesarias para torch y lxml)
|
| 18 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 19 |
+
gcc \
|
| 20 |
+
g++ \
|
| 21 |
+
libxml2-dev \
|
| 22 |
+
libxslt-dev \
|
| 23 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 24 |
+
|
| 25 |
+
# Copiar solo el requirements primero (aprovecha cache de Docker)
|
| 26 |
+
COPY requirements.txt .
|
| 27 |
+
|
| 28 |
+
# Instalar dependencias Python
|
| 29 |
+
# --no-cache-dir reduce el tamaño de imagen
|
| 30 |
+
RUN pip install --upgrade pip && \
|
| 31 |
+
pip install --no-cache-dir -r requirements.txt && \
|
| 32 |
+
pip install --no-cache-dir gunicorn
|
| 33 |
+
|
| 34 |
+
# Copiar el código del proyecto
|
| 35 |
+
COPY . .
|
| 36 |
+
|
| 37 |
+
# Descargar recursos NLTK necesarios durante el build
|
| 38 |
+
RUN python -c "import nltk; nltk.download('punkt', quiet=True); nltk.download('punkt_tab', quiet=True)"
|
| 39 |
+
|
| 40 |
+
# Puerto que expone la app
|
| 41 |
+
EXPOSE 8050
|
| 42 |
+
|
| 43 |
+
# Comando de inicio con Gunicorn
|
| 44 |
+
# - 2 workers (ajustar según CPU disponibles: 2 * num_cpus + 1)
|
| 45 |
+
# - timeout 300s porque el análisis NLP puede tardar varios minutos
|
| 46 |
+
# - El objeto WSGI de Dash se llama "server" dentro de app.py
|
| 47 |
+
CMD ["gunicorn", \
|
| 48 |
+
"--workers", "2", \
|
| 49 |
+
"--timeout", "300", \
|
| 50 |
+
"--bind", "0.0.0.0:8050", \
|
| 51 |
+
"--log-level", "info", \
|
| 52 |
+
"--access-logfile", "-", \
|
| 53 |
+
"--error-logfile", "-", \
|
| 54 |
+
"app:server"]
|
Modelo_Pymes.pkl
ADDED
|
Binary file (1.81 kB). View file
|
|
|
README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AIM Dashboard
|
| 2 |
+
|
| 3 |
+
**Herramienta de clasificación de perfil de ciberseguridad para PyMEs** basada en la
|
| 4 |
+
Tríada AIM (Awareness, Infrastructure, Management).
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## Cómo verlo en localhost
|
| 9 |
+
|
| 10 |
+
### Opción A — Python directo (más rápido para probar)
|
| 11 |
+
|
| 12 |
+
**1. Descomprime el proyecto y entra a la carpeta**
|
| 13 |
+
```bash
|
| 14 |
+
cd aim_dashboard
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
**2. Crea un entorno virtual**
|
| 18 |
+
```bash
|
| 19 |
+
python -m venv venv
|
| 20 |
+
|
| 21 |
+
# macOS / Linux:
|
| 22 |
+
source venv/bin/activate
|
| 23 |
+
|
| 24 |
+
# Windows:
|
| 25 |
+
venv\Scripts\activate
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
**3. Instala las dependencias**
|
| 29 |
+
```bash
|
| 30 |
+
pip install -r requirements.txt
|
| 31 |
+
```
|
| 32 |
+
La primera vez tarda varios minutos porque descarga modelos de ~1.5 GB (PyTorch, BART, DeBERTa, MPNet).
|
| 33 |
+
|
| 34 |
+
**4. Verifica que el modelo esté en la carpeta raíz**
|
| 35 |
+
```
|
| 36 |
+
aim_dashboard/
|
| 37 |
+
├── app.py
|
| 38 |
+
├── Modelo_Pymes.pkl ← debe estar aquí
|
| 39 |
+
└── ...
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
**5. Ejecuta**
|
| 43 |
+
```bash
|
| 44 |
+
python app.py
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
**6. Abre en el navegador:**
|
| 48 |
+
```
|
| 49 |
+
http://localhost:8050
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
---
|
| 53 |
+
|
| 54 |
+
### Opción B — Docker (recomendado para producción)
|
| 55 |
+
|
| 56 |
+
Requisitos: Docker Desktop instalado.
|
| 57 |
+
|
| 58 |
+
```bash
|
| 59 |
+
cd aim_dashboard
|
| 60 |
+
docker-compose up --build
|
| 61 |
+
```
|
| 62 |
+
La primera vez tarda ~5-10 minutos. Luego abre `http://localhost:8050`.
|
| 63 |
+
|
| 64 |
+
Para detener: `docker-compose down`
|
| 65 |
+
|
| 66 |
+
---
|
| 67 |
+
|
| 68 |
+
### Opción C — Gunicorn (producción sin Docker)
|
| 69 |
+
|
| 70 |
+
```bash
|
| 71 |
+
pip install gunicorn
|
| 72 |
+
gunicorn --workers 2 --timeout 300 --bind 0.0.0.0:8050 app:server
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
---
|
| 76 |
+
|
| 77 |
+
## Estructura del proyecto
|
| 78 |
+
|
| 79 |
+
```
|
| 80 |
+
aim_dashboard/
|
| 81 |
+
│
|
| 82 |
+
├── app.py <- Punto de entrada principal
|
| 83 |
+
├── Dockerfile <- Imagen Docker
|
| 84 |
+
├── docker-compose.yml <- Orquestación
|
| 85 |
+
├── requirements.txt
|
| 86 |
+
├── Modelo_Pymes.pkl <- Modelo K-Means (NO subir a repositorios públicos)
|
| 87 |
+
│
|
| 88 |
+
├── assets/
|
| 89 |
+
│ └── style.css <- Estilos (Dash los carga automáticamente)
|
| 90 |
+
│
|
| 91 |
+
├── data/
|
| 92 |
+
│ └── definitions.py <- Constantes y textos estáticos
|
| 93 |
+
│
|
| 94 |
+
├── layouts/
|
| 95 |
+
│ └── pages.py <- Pantallas de home y perfiles
|
| 96 |
+
│
|
| 97 |
+
├── callbacks/
|
| 98 |
+
│ └── navegacion.py <- Análisis en background, barra de progreso, routing
|
| 99 |
+
│
|
| 100 |
+
└── logic/
|
| 101 |
+
├── extractor.py <- Scraping y clasificación semántica
|
| 102 |
+
├── modelo.py <- Vectorización NLP + predicción K-Means
|
| 103 |
+
└── venn.py <- Diagrama de Venn
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
---
|
| 107 |
+
|
| 108 |
+
## Flujo de la aplicación
|
| 109 |
+
|
| 110 |
+
```
|
| 111 |
+
URL ingresada
|
| 112 |
+
-> ExtractorMVD: scraping de páginas relevantes (Paso 1)
|
| 113 |
+
-> clasificar_inteligente: clasifica en MISION/VISION/DESCRIPCION
|
| 114 |
+
-> traducción ES->EN (Paso 2)
|
| 115 |
+
-> vectorización NLP: MPNet + DeBERTa + BART zero-shot (Paso 3)
|
| 116 |
+
-> KMeans.predict: cluster 0-4 -> Perfil 1-5
|
| 117 |
+
-> Layout del perfil con fortalezas, debilidades y Venn
|
| 118 |
+
```
|
| 119 |
+
|
| 120 |
+
---
|
| 121 |
+
|
| 122 |
+
## Problemas comunes
|
| 123 |
+
|
| 124 |
+
| Problema | Solución |
|
| 125 |
+
|---|---|
|
| 126 |
+
| `FileNotFoundError: Modelo_Pymes.pkl` | Pon el `.pkl` junto a `app.py` |
|
| 127 |
+
| La app tarda en arrancar | Normal: los modelos NLP se cargan al primer análisis |
|
| 128 |
+
| Error de red al analizar sitio | Verifica que la URL incluya `https://` |
|
| 129 |
+
| `Port 8050 already in use` | Cambia el puerto en `app.py` o mata el proceso: `lsof -ti:8050 \| xargs kill` |
|
| 130 |
+
| Docker con poca memoria | Asigna al menos 4 GB de RAM en Docker Desktop > Settings > Resources |
|
| 131 |
+
|
| 132 |
+
---
|
| 133 |
+
|
| 134 |
+
## Notas para producción
|
| 135 |
+
|
| 136 |
+
- El análisis NLP corre en un **thread separado** para no bloquear la UI. Con múltiples
|
| 137 |
+
usuarios simultáneos considera usar **Celery + Redis** para una cola de trabajos.
|
| 138 |
+
- Los modelos de HuggingFace se cachean en `~/.cache/huggingface/`. En Docker se
|
| 139 |
+
descargan al construir la imagen.
|
| 140 |
+
- Para exponer con dominio real, descomenta el servicio `nginx` en `docker-compose.yml`
|
| 141 |
+
y configura tu dominio.
|
app.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AIM Dashboard — Punto de entrada principal.
|
| 3 |
+
Ejecutar con: python app.py
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
import dash
|
| 8 |
+
import dash_bootstrap_components as dbc
|
| 9 |
+
from dash import dcc, html
|
| 10 |
+
|
| 11 |
+
from layouts.pages import layout_home, all_layouts
|
| 12 |
+
from callbacks.navegacion import registrar_callbacks
|
| 13 |
+
|
| 14 |
+
logging.basicConfig(
|
| 15 |
+
level=logging.INFO,
|
| 16 |
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
app = dash.Dash(
|
| 20 |
+
__name__,
|
| 21 |
+
suppress_callback_exceptions=True,
|
| 22 |
+
external_stylesheets=[dbc.themes.BOOTSTRAP],
|
| 23 |
+
meta_tags=[{"name": "viewport", "content": "width=device-width, initial-scale=1"}],
|
| 24 |
+
)
|
| 25 |
+
app.title = "AIM Dashboard"
|
| 26 |
+
|
| 27 |
+
# ── Panel de progreso en el layout raíz ──────────────────────────────────────
|
| 28 |
+
# Debe estar aquí (no en page-content) para que los callbacks siempre
|
| 29 |
+
# puedan escribir en él sin importar qué página está cargada.
|
| 30 |
+
# Se muestra como barra fija en la parte inferior de la pantalla.
|
| 31 |
+
panel_progreso = html.Div(
|
| 32 |
+
id="panel-progreso",
|
| 33 |
+
style={"display": "none"},
|
| 34 |
+
children=[
|
| 35 |
+
html.Div(
|
| 36 |
+
style={
|
| 37 |
+
"position": "fixed",
|
| 38 |
+
"bottom": "0", "left": "0", "right": "0",
|
| 39 |
+
"zIndex": "1000",
|
| 40 |
+
"background": "#ffffff",
|
| 41 |
+
"borderTop": "3px solid #1c3160",
|
| 42 |
+
"boxShadow": "0 -4px 24px rgba(28,49,96,0.15)",
|
| 43 |
+
"padding": "18px 36px 20px",
|
| 44 |
+
},
|
| 45 |
+
children=[
|
| 46 |
+
html.Div(
|
| 47 |
+
style={"maxWidth": "860px", "margin": "0 auto"},
|
| 48 |
+
children=[
|
| 49 |
+
html.Div(
|
| 50 |
+
style={"display": "flex", "alignItems": "center",
|
| 51 |
+
"justifyContent": "space-between", "marginBottom": "10px"},
|
| 52 |
+
children=[
|
| 53 |
+
html.Span("Analizando perfil AIM", style={
|
| 54 |
+
"fontSize": "0.78rem", "fontWeight": "700",
|
| 55 |
+
"letterSpacing": "1.2px", "textTransform": "uppercase",
|
| 56 |
+
"color": "#1c3160",
|
| 57 |
+
}),
|
| 58 |
+
html.Span(id="progreso-pct", style={
|
| 59 |
+
"fontSize": "1rem", "fontWeight": "700",
|
| 60 |
+
"color": "#b87a2a",
|
| 61 |
+
}),
|
| 62 |
+
],
|
| 63 |
+
),
|
| 64 |
+
html.Div(
|
| 65 |
+
style={
|
| 66 |
+
"background": "#e8ecf2",
|
| 67 |
+
"borderRadius": "999px",
|
| 68 |
+
"height": "14px",
|
| 69 |
+
"marginBottom": "14px",
|
| 70 |
+
"overflow": "hidden",
|
| 71 |
+
"border": "2px solid #b0bacb",
|
| 72 |
+
"boxShadow": "inset 0 1px 3px rgba(0,0,0,0.1)",
|
| 73 |
+
},
|
| 74 |
+
children=[
|
| 75 |
+
html.Div(
|
| 76 |
+
id="progreso-bar",
|
| 77 |
+
style={
|
| 78 |
+
"height": "100%", "borderRadius": "999px",
|
| 79 |
+
"background": "linear-gradient(90deg, #b87a2a, #d4a843)",
|
| 80 |
+
"width": "0%",
|
| 81 |
+
"transition": "width 0.8s ease",
|
| 82 |
+
"boxShadow": "0 1px 4px rgba(184,122,42,0.4)",
|
| 83 |
+
},
|
| 84 |
+
)
|
| 85 |
+
],
|
| 86 |
+
),
|
| 87 |
+
html.Div(id="progreso-container"),
|
| 88 |
+
html.Div(id="error-msg", children=""),
|
| 89 |
+
],
|
| 90 |
+
)
|
| 91 |
+
],
|
| 92 |
+
)
|
| 93 |
+
],
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
# ── Layout raíz ───────────────────────────────────────────────────────────────
|
| 97 |
+
app.layout = html.Div([
|
| 98 |
+
dcc.Location(id="url", refresh=False),
|
| 99 |
+
dcc.Store(id="store_name", storage_type="session"),
|
| 100 |
+
dcc.Store(id="store_link", storage_type="session"),
|
| 101 |
+
dcc.Interval(id="interval_progress", interval=600, n_intervals=0, disabled=True),
|
| 102 |
+
html.Div(id="page-content"),
|
| 103 |
+
panel_progreso,
|
| 104 |
+
])
|
| 105 |
+
|
| 106 |
+
# ── Routing ───────────────────────────────────────────────────────────────────
|
| 107 |
+
@app.callback(
|
| 108 |
+
dash.Output("page-content", "children"),
|
| 109 |
+
dash.Input("url", "pathname"),
|
| 110 |
+
)
|
| 111 |
+
def mostrar_pagina(pathname: str):
|
| 112 |
+
rutas = {
|
| 113 |
+
"/profile_1": all_layouts[0],
|
| 114 |
+
"/profile_2": all_layouts[1],
|
| 115 |
+
"/profile_3": all_layouts[2],
|
| 116 |
+
"/profile_4": all_layouts[3],
|
| 117 |
+
"/profile_5": all_layouts[4],
|
| 118 |
+
}
|
| 119 |
+
return rutas.get(pathname, layout_home)
|
| 120 |
+
|
| 121 |
+
registrar_callbacks(app)
|
| 122 |
+
|
| 123 |
+
server = app.server # para Gunicorn: gunicorn app:server
|
| 124 |
+
|
| 125 |
+
if __name__ == "__main__":
|
| 126 |
+
app.run(debug=False, host="0.0.0.0", port=8050)
|
assets/style.css
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
AIM Dashboard — Rediseño accesible
|
| 3 |
+
Principios: alto contraste, tipografía generosa, espaciado amplio,
|
| 4 |
+
paleta sobria institucional (azul profundo + blanco cálido + ámbar)
|
| 5 |
+
============================================================ */
|
| 6 |
+
|
| 7 |
+
/* ---------- Fuente legible y serif elegante para encabezados ---------- */
|
| 8 |
+
@import url('https://fonts.googleapis.com/css2?family=Source+Serif+4:wght@400;600;700&family=Source+Sans+3:wght@400;500;600;700&display=swap');
|
| 9 |
+
|
| 10 |
+
*, *::before, *::after { box-sizing: border-box; }
|
| 11 |
+
|
| 12 |
+
body {
|
| 13 |
+
margin: 0;
|
| 14 |
+
font-family: 'Source Sans 3', 'Segoe UI', Georgia, sans-serif;
|
| 15 |
+
background-color: #f4f1eb; /* blanco cálido, no agresivo */
|
| 16 |
+
color: #1c2233; /* azul muy oscuro, no negro puro */
|
| 17 |
+
min-height: 100vh;
|
| 18 |
+
font-size: 17px; /* base más grande que el estándar */
|
| 19 |
+
line-height: 1.7;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
/* ---------- Variables de paleta institucional ---------- */
|
| 23 |
+
:root {
|
| 24 |
+
--bg: #f4f1eb; /* fondo principal: crema cálida */
|
| 25 |
+
--bg-card: #ffffff; /* tarjetas blancas */
|
| 26 |
+
--bg-card-alt: #eef2f7; /* tarjetas secundarias: azul muy pálido */
|
| 27 |
+
--border: #c8d0df; /* bordes suaves */
|
| 28 |
+
--border-dark: #8a96aa; /* bordes énfasis */
|
| 29 |
+
|
| 30 |
+
--navy: #1c3160; /* azul marino profundo — color primario */
|
| 31 |
+
--navy-mid: #2d4f8a; /* azul medio para hover */
|
| 32 |
+
--navy-light: #dde6f5; /* azul muy claro para fondos */
|
| 33 |
+
|
| 34 |
+
--amber: #b87a2a; /* ámbar cálido — acento positivo */
|
| 35 |
+
--amber-light: #fdf3e3; /* fondo ámbar suave */
|
| 36 |
+
|
| 37 |
+
--success: #1e6b45; /* verde bosque oscuro */
|
| 38 |
+
--success-bg: #e6f4ed;
|
| 39 |
+
--success-border: #7dc4a0;
|
| 40 |
+
|
| 41 |
+
--danger: #8b1c1c; /* rojo ladrillo oscuro */
|
| 42 |
+
--danger-bg: #fdeaea;
|
| 43 |
+
--danger-border: #d48585;
|
| 44 |
+
|
| 45 |
+
--text-primary: #1c2233;
|
| 46 |
+
--text-secondary:#3d4a60;
|
| 47 |
+
--text-muted: #6b7a96;
|
| 48 |
+
--text-light: #9aa3b5;
|
| 49 |
+
|
| 50 |
+
--radius: 10px;
|
| 51 |
+
--radius-lg: 14px;
|
| 52 |
+
--shadow-sm: 0 1px 4px rgba(28,49,96,0.08);
|
| 53 |
+
--shadow: 0 3px 16px rgba(28,49,96,0.12);
|
| 54 |
+
--shadow-lg: 0 8px 32px rgba(28,49,96,0.16);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/* ---------- Contenedor principal ---------- */
|
| 58 |
+
.aim-container {
|
| 59 |
+
max-width: 1340px;
|
| 60 |
+
margin: 0 auto;
|
| 61 |
+
padding: 28px 36px;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/* ---------- Header ---------- */
|
| 65 |
+
.aim-header {
|
| 66 |
+
text-align: center;
|
| 67 |
+
padding: 48px 0 40px;
|
| 68 |
+
border-bottom: 2px solid var(--border);
|
| 69 |
+
margin-bottom: 36px;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.aim-header h1 {
|
| 73 |
+
font-family: 'Source Serif 4', Georgia, serif;
|
| 74 |
+
font-size: 2.6rem;
|
| 75 |
+
font-weight: 700;
|
| 76 |
+
color: var(--navy);
|
| 77 |
+
letter-spacing: -0.3px;
|
| 78 |
+
margin: 0 0 10px;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.aim-header .subtitle {
|
| 82 |
+
color: var(--text-secondary);
|
| 83 |
+
font-size: 1.15rem;
|
| 84 |
+
margin: 0;
|
| 85 |
+
font-weight: 400;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
/* ---------- Layout de dos columnas ---------- */
|
| 89 |
+
.aim-two-col {
|
| 90 |
+
display: flex;
|
| 91 |
+
gap: 28px;
|
| 92 |
+
align-items: flex-start;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.aim-two-col .col-left { flex: 1; min-width: 0; }
|
| 96 |
+
.aim-two-col .col-right { flex: 1; min-width: 0; text-align: center; }
|
| 97 |
+
|
| 98 |
+
@media (max-width: 960px) {
|
| 99 |
+
.aim-two-col { flex-direction: column; }
|
| 100 |
+
.aim-container { padding: 20px 18px; }
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
/* ---------- Cards ---------- */
|
| 104 |
+
.aim-card {
|
| 105 |
+
background: var(--bg-card);
|
| 106 |
+
border: 1.5px solid var(--border);
|
| 107 |
+
border-radius: var(--radius-lg);
|
| 108 |
+
padding: 28px 32px;
|
| 109 |
+
margin-bottom: 20px;
|
| 110 |
+
box-shadow: var(--shadow-sm);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.aim-card-label {
|
| 114 |
+
font-size: 0.78rem;
|
| 115 |
+
font-weight: 700;
|
| 116 |
+
letter-spacing: 1.4px;
|
| 117 |
+
text-transform: uppercase;
|
| 118 |
+
color: var(--navy);
|
| 119 |
+
margin-bottom: 10px;
|
| 120 |
+
display: flex;
|
| 121 |
+
align-items: center;
|
| 122 |
+
gap: 8px;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.aim-card-label::before {
|
| 126 |
+
content: '';
|
| 127 |
+
display: inline-block;
|
| 128 |
+
width: 4px;
|
| 129 |
+
height: 16px;
|
| 130 |
+
background: var(--amber);
|
| 131 |
+
border-radius: 2px;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
/* ---------- Inputs ---------- */
|
| 135 |
+
.aim-input {
|
| 136 |
+
width: 100%;
|
| 137 |
+
padding: 14px 18px;
|
| 138 |
+
background: #fafbfd;
|
| 139 |
+
border: 2px solid var(--border);
|
| 140 |
+
border-radius: var(--radius);
|
| 141 |
+
color: var(--text-primary);
|
| 142 |
+
font-size: 1.05rem;
|
| 143 |
+
font-family: inherit;
|
| 144 |
+
transition: border-color 0.2s, box-shadow 0.2s;
|
| 145 |
+
margin-bottom: 18px;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.aim-input:focus {
|
| 149 |
+
outline: none;
|
| 150 |
+
border-color: var(--navy-mid);
|
| 151 |
+
box-shadow: 0 0 0 3px rgba(45,79,138,0.15);
|
| 152 |
+
background: #fff;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.aim-input::placeholder { color: var(--text-light); }
|
| 156 |
+
|
| 157 |
+
/* Labels de formulario */
|
| 158 |
+
label {
|
| 159 |
+
display: block;
|
| 160 |
+
font-weight: 600;
|
| 161 |
+
font-size: 1rem;
|
| 162 |
+
color: var(--text-secondary);
|
| 163 |
+
margin-bottom: 6px;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
/* ---------- Botón principal ---------- */
|
| 167 |
+
.aim-btn-primary {
|
| 168 |
+
width: 100%;
|
| 169 |
+
padding: 16px 28px;
|
| 170 |
+
background: var(--navy);
|
| 171 |
+
color: #ffffff;
|
| 172 |
+
border: none;
|
| 173 |
+
border-radius: var(--radius);
|
| 174 |
+
font-size: 1.08rem;
|
| 175 |
+
font-weight: 700;
|
| 176 |
+
font-family: inherit;
|
| 177 |
+
letter-spacing: 0.3px;
|
| 178 |
+
cursor: pointer;
|
| 179 |
+
transition: background 0.2s, box-shadow 0.2s, transform 0.1s;
|
| 180 |
+
margin-top: 8px;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.aim-btn-primary:hover {
|
| 184 |
+
background: var(--navy-mid);
|
| 185 |
+
box-shadow: 0 4px 18px rgba(28,49,96,0.28);
|
| 186 |
+
transform: translateY(-1px);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.aim-btn-primary:active { transform: translateY(0); }
|
| 190 |
+
|
| 191 |
+
/* Botón secundario (volver) */
|
| 192 |
+
.aim-btn-secondary {
|
| 193 |
+
padding: 10px 22px;
|
| 194 |
+
background: var(--bg-card);
|
| 195 |
+
color: var(--navy);
|
| 196 |
+
border: 2px solid var(--navy);
|
| 197 |
+
border-radius: var(--radius);
|
| 198 |
+
font-size: 0.97rem;
|
| 199 |
+
font-weight: 700;
|
| 200 |
+
font-family: inherit;
|
| 201 |
+
cursor: pointer;
|
| 202 |
+
transition: background 0.2s;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.aim-btn-secondary:hover {
|
| 206 |
+
background: var(--navy-light);
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
/* ---------- Tags de dominio ---------- */
|
| 210 |
+
.domain-tag {
|
| 211 |
+
display: inline-block;
|
| 212 |
+
padding: 5px 13px;
|
| 213 |
+
border-radius: 999px;
|
| 214 |
+
font-size: 0.85rem;
|
| 215 |
+
font-weight: 600;
|
| 216 |
+
margin: 3px 3px;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.domain-tag.strength {
|
| 220 |
+
background: var(--success-bg);
|
| 221 |
+
color: var(--success);
|
| 222 |
+
border: 1.5px solid var(--success-border);
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
.domain-tag.weakness {
|
| 226 |
+
background: var(--danger-bg);
|
| 227 |
+
color: var(--danger);
|
| 228 |
+
border: 1.5px solid var(--danger-border);
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
/* ---------- Texto de definición ---------- */
|
| 232 |
+
.aim-definition {
|
| 233 |
+
color: var(--text-secondary);
|
| 234 |
+
font-size: 1rem;
|
| 235 |
+
line-height: 1.8;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
.aim-definition p { margin: 0 0 12px; }
|
| 239 |
+
.aim-definition strong { color: var(--navy); }
|
| 240 |
+
|
| 241 |
+
/* ---------- Imagen Venn ---------- */
|
| 242 |
+
.venn-img {
|
| 243 |
+
width: 100%;
|
| 244 |
+
max-width: 100%;
|
| 245 |
+
height: auto;
|
| 246 |
+
border-radius: var(--radius);
|
| 247 |
+
box-shadow: var(--shadow);
|
| 248 |
+
border: 1.5px solid var(--border);
|
| 249 |
+
display: block;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
/* ---------- Alerta de error ---------- */
|
| 253 |
+
.aim-error {
|
| 254 |
+
background: var(--danger-bg);
|
| 255 |
+
border: 1.5px solid var(--danger-border);
|
| 256 |
+
border-radius: var(--radius);
|
| 257 |
+
padding: 14px 18px;
|
| 258 |
+
color: var(--danger);
|
| 259 |
+
font-size: 0.95rem;
|
| 260 |
+
margin-top: 12px;
|
| 261 |
+
font-weight: 500;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
/* ---------- Navegación flotante (botón volver) ---------- */
|
| 265 |
+
.aim-nav-home {
|
| 266 |
+
position: fixed;
|
| 267 |
+
top: 18px;
|
| 268 |
+
right: 24px;
|
| 269 |
+
z-index: 999;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
/* ---------- Panel de progreso ---------- */
|
| 273 |
+
#panel-progreso .aim-card {
|
| 274 |
+
border-left: 4px solid var(--navy);
|
| 275 |
+
background: var(--bg-card);
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
/* ---------- Acordeón Details/Summary ---------- */
|
| 279 |
+
details {
|
| 280 |
+
border-radius: var(--radius);
|
| 281 |
+
margin-bottom: 10px;
|
| 282 |
+
overflow: hidden;
|
| 283 |
+
border: 1.5px solid var(--border);
|
| 284 |
+
transition: box-shadow 0.2s;
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
details:hover {
|
| 288 |
+
box-shadow: var(--shadow-sm);
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
details[open] {
|
| 292 |
+
box-shadow: var(--shadow);
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
details summary {
|
| 296 |
+
outline: none;
|
| 297 |
+
list-style: none;
|
| 298 |
+
padding: 14px 18px;
|
| 299 |
+
cursor: pointer;
|
| 300 |
+
font-weight: 600;
|
| 301 |
+
font-size: 0.98rem;
|
| 302 |
+
user-select: none;
|
| 303 |
+
transition: background 0.15s;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
details summary::-webkit-details-marker { display: none; }
|
| 307 |
+
details summary::marker { display: none; }
|
| 308 |
+
|
| 309 |
+
details summary:hover {
|
| 310 |
+
filter: brightness(0.97);
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
details[open] > div {
|
| 314 |
+
animation: fadeSlide 0.22s ease;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
@keyframes fadeSlide {
|
| 318 |
+
from { opacity: 0; transform: translateY(-5px); }
|
| 319 |
+
to { opacity: 1; transform: translateY(0); }
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
/* ---------- Cabecera de columna de dominios ---------- */
|
| 323 |
+
.aim-domain-col-header {
|
| 324 |
+
font-size: 0.78rem;
|
| 325 |
+
font-weight: 700;
|
| 326 |
+
letter-spacing: 1.3px;
|
| 327 |
+
text-transform: uppercase;
|
| 328 |
+
margin-bottom: 14px;
|
| 329 |
+
padding-bottom: 12px;
|
| 330 |
+
border-bottom: 2px solid;
|
| 331 |
+
display: flex;
|
| 332 |
+
align-items: center;
|
| 333 |
+
gap: 9px;
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
/* ---------- Grid 3 columnas responsive ---------- */
|
| 337 |
+
@media (max-width: 1100px) {
|
| 338 |
+
.aim-profile-grid { grid-template-columns: 1fr 1fr !important; }
|
| 339 |
+
}
|
| 340 |
+
@media (max-width: 680px) {
|
| 341 |
+
.aim-profile-grid { grid-template-columns: 1fr !important; }
|
| 342 |
+
.aim-header h1 { font-size: 1.9rem; }
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
/* ---------- Scrollbar suavizado ---------- */
|
| 346 |
+
::-webkit-scrollbar { width: 8px; }
|
| 347 |
+
::-webkit-scrollbar-track { background: var(--bg); }
|
| 348 |
+
::-webkit-scrollbar-thumb {
|
| 349 |
+
background: var(--border-dark);
|
| 350 |
+
border-radius: 4px;
|
| 351 |
+
}
|
| 352 |
+
::-webkit-scrollbar-thumb:hover { background: var(--navy-mid); }
|
| 353 |
+
|
| 354 |
+
/* ---------- Selección de texto ---------- */
|
| 355 |
+
::selection {
|
| 356 |
+
background: var(--navy-light);
|
| 357 |
+
color: var(--navy);
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
/* ---------- Zoom modal del diagrama Venn ---------- */
|
| 361 |
+
.venn-clickable {
|
| 362 |
+
transition: transform 0.2s, box-shadow 0.2s;
|
| 363 |
+
}
|
| 364 |
+
.venn-clickable:hover {
|
| 365 |
+
transform: scale(1.02);
|
| 366 |
+
box-shadow: 0 6px 24px rgba(28,49,96,0.2);
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
.venn-modal-overlay {
|
| 370 |
+
position: fixed;
|
| 371 |
+
inset: 0;
|
| 372 |
+
background: rgba(10, 15, 30, 0.75);
|
| 373 |
+
z-index: 9999;
|
| 374 |
+
display: flex !important;
|
| 375 |
+
align-items: center;
|
| 376 |
+
justify-content: center;
|
| 377 |
+
padding: 24px;
|
| 378 |
+
backdrop-filter: blur(3px);
|
| 379 |
+
animation: fadeIn 0.2s ease;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
.venn-modal-overlay[style*="display: none"] {
|
| 383 |
+
display: none !important;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
@keyframes fadeIn {
|
| 387 |
+
from { opacity: 0; }
|
| 388 |
+
to { opacity: 1; }
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
.venn-modal-box {
|
| 392 |
+
background: #fff;
|
| 393 |
+
border-radius: 14px;
|
| 394 |
+
padding: 24px;
|
| 395 |
+
max-width: 820px;
|
| 396 |
+
width: 100%;
|
| 397 |
+
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
| 398 |
+
position: relative;
|
| 399 |
+
animation: scaleIn 0.2s ease;
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
@keyframes scaleIn {
|
| 403 |
+
from { transform: scale(0.92); opacity: 0; }
|
| 404 |
+
to { transform: scale(1); opacity: 1; }
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
.venn-modal-img {
|
| 408 |
+
width: 100%;
|
| 409 |
+
height: auto;
|
| 410 |
+
border-radius: 8px;
|
| 411 |
+
display: block;
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
.venn-modal-close {
|
| 415 |
+
position: absolute;
|
| 416 |
+
top: 12px;
|
| 417 |
+
right: 12px;
|
| 418 |
+
background: var(--navy);
|
| 419 |
+
color: #fff;
|
| 420 |
+
border: none;
|
| 421 |
+
border-radius: 8px;
|
| 422 |
+
padding: 8px 16px;
|
| 423 |
+
font-size: 0.9rem;
|
| 424 |
+
font-weight: 700;
|
| 425 |
+
font-family: inherit;
|
| 426 |
+
cursor: pointer;
|
| 427 |
+
transition: background 0.2s;
|
| 428 |
+
}
|
| 429 |
+
.venn-modal-close:hover {
|
| 430 |
+
background: var(--navy-mid);
|
| 431 |
+
}
|
callbacks/__init__.py
ADDED
|
File without changes
|
callbacks/navegacion.py
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Callbacks del dashboard AIM.
|
| 3 |
+
- error-msg, progreso-container, progreso-bar, progreso-pct viven en pages.py
|
| 4 |
+
dentro del panel inline bajo el botón.
|
| 5 |
+
- El panel se muestra/oculta via Output("panel-progreso", "style").
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
import threading
|
| 10 |
+
import time
|
| 11 |
+
|
| 12 |
+
import dash
|
| 13 |
+
from dash import ctx, html
|
| 14 |
+
from dash.dependencies import Input, Output, State, ALL
|
| 15 |
+
|
| 16 |
+
from logic.extractor import ExtractorMVD
|
| 17 |
+
from logic.modelo import obtener_perfil
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
# ── Estado compartido ─────────────────────────────────────────────────────────
|
| 22 |
+
_estado: dict = {"paso": 0, "pct": 0, "perfil": None, "error": None}
|
| 23 |
+
_lock = threading.Lock()
|
| 24 |
+
|
| 25 |
+
# Porcentaje base al inicio de cada paso
|
| 26 |
+
_PCT_BASE = {1: 0, 2: 30, 3: 50, 4: 100}
|
| 27 |
+
# Porcentaje máximo al que puede llegar solo con el timer (sin avanzar de paso)
|
| 28 |
+
_PCT_TECHO = {1: 28, 2: 48, 3: 95, 4: 100}
|
| 29 |
+
|
| 30 |
+
PASOS_LABELS = ["Extrayendo", "Procesando", "Clasificando", "Listo"]
|
| 31 |
+
PASOS_DETALLE = [
|
| 32 |
+
"Extrayendo contenido del sitio web...",
|
| 33 |
+
"Traduciendo y procesando texto...",
|
| 34 |
+
"Clasificando perfil con el modelo NLP...",
|
| 35 |
+
"¡Análisis completado!",
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
_PANEL_VISIBLE = {"display": "block"}
|
| 39 |
+
_PANEL_OCULTO = {"display": "none"}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ── Thread principal de análisis ──────────────────────────────────────────────
|
| 43 |
+
|
| 44 |
+
def _correr_analisis(link: str, nombre: str) -> None:
|
| 45 |
+
global _estado
|
| 46 |
+
try:
|
| 47 |
+
# Paso 1 — Extracción web
|
| 48 |
+
with _lock:
|
| 49 |
+
_estado = {"paso": 1, "pct": 0, "perfil": None, "error": None}
|
| 50 |
+
|
| 51 |
+
extractor = ExtractorMVD(url=link, nombre=nombre)
|
| 52 |
+
extractor.navegar_y_extraer()
|
| 53 |
+
texto = extractor.clasificar_inteligente()
|
| 54 |
+
|
| 55 |
+
if not texto:
|
| 56 |
+
with _lock:
|
| 57 |
+
_estado["error"] = {
|
| 58 |
+
"tipo": "extraccion",
|
| 59 |
+
"titulo": "No se encontró contenido relevante",
|
| 60 |
+
"detalle": (
|
| 61 |
+
"El sitio web no contiene texto relacionado con misión, visión "
|
| 62 |
+
"o descripción organizacional que el modelo pueda analizar."
|
| 63 |
+
),
|
| 64 |
+
"sugerencia": "Prueba con la URL de la página 'Quiénes somos' o 'Acerca de' de la empresa.",
|
| 65 |
+
}
|
| 66 |
+
_estado["paso"] = -1
|
| 67 |
+
return
|
| 68 |
+
|
| 69 |
+
# Paso 2 — Traducción
|
| 70 |
+
with _lock:
|
| 71 |
+
_estado["paso"] = 2
|
| 72 |
+
_estado["pct"] = _PCT_BASE[2]
|
| 73 |
+
|
| 74 |
+
# Paso 3 — Clasificación NLP + K-Means
|
| 75 |
+
with _lock:
|
| 76 |
+
_estado["paso"] = 3
|
| 77 |
+
_estado["pct"] = _PCT_BASE[3]
|
| 78 |
+
|
| 79 |
+
perfil = obtener_perfil(texto) + 1 # 0-4 → 1-5
|
| 80 |
+
|
| 81 |
+
# Paso 4 — Listo
|
| 82 |
+
with _lock:
|
| 83 |
+
_estado["paso"] = 4
|
| 84 |
+
_estado["pct"] = 100
|
| 85 |
+
_estado["perfil"] = perfil
|
| 86 |
+
|
| 87 |
+
logger.info("Perfil: %d | '%s'", perfil, link)
|
| 88 |
+
|
| 89 |
+
except FileNotFoundError as e:
|
| 90 |
+
logger.error("Modelo no encontrado: %s", e)
|
| 91 |
+
with _lock:
|
| 92 |
+
_estado["error"] = {
|
| 93 |
+
"tipo": "modelo",
|
| 94 |
+
"titulo": "Modelo no encontrado",
|
| 95 |
+
"detalle": "El archivo Modelo_Pymes.pkl no está en la carpeta del proyecto.",
|
| 96 |
+
"sugerencia": "Verifica que Modelo_Pymes.pkl esté en la carpeta raíz junto a app.py.",
|
| 97 |
+
}
|
| 98 |
+
_estado["paso"] = -1
|
| 99 |
+
except Exception as e:
|
| 100 |
+
logger.exception("Error procesando '%s'", link)
|
| 101 |
+
err_str = str(e)
|
| 102 |
+
# Clasificar el error según su contenido
|
| 103 |
+
if "connection" in err_str.lower() or "timeout" in err_str.lower() or "urlopen" in err_str.lower():
|
| 104 |
+
tipo = "red"
|
| 105 |
+
titulo = "Error de conexión"
|
| 106 |
+
detalle = f"No se pudo conectar al sitio: {err_str}"
|
| 107 |
+
sugiere = "Verifica que la URL sea correcta y que el sitio esté accesible desde tu red."
|
| 108 |
+
elif "ssl" in err_str.lower() or "certificate" in err_str.lower():
|
| 109 |
+
tipo = "ssl"
|
| 110 |
+
titulo = "Error de certificado SSL"
|
| 111 |
+
detalle = "El sitio tiene un certificado de seguridad inválido o expirado."
|
| 112 |
+
sugiere = "Intenta con la versión http:// en lugar de https://, o prueba con otro sitio."
|
| 113 |
+
elif "403" in err_str or "401" in err_str or "forbidden" in err_str.lower():
|
| 114 |
+
tipo = "acceso"
|
| 115 |
+
titulo = "Acceso denegado por el sitio"
|
| 116 |
+
detalle = "El servidor rechazó la solicitud (error 403/401)."
|
| 117 |
+
sugiere = "Este sitio bloquea el acceso automatizado. Prueba con otro sitio o con la URL de una subpágina."
|
| 118 |
+
elif "404" in err_str or "not found" in err_str.lower():
|
| 119 |
+
tipo = "url"
|
| 120 |
+
titulo = "Página no encontrada"
|
| 121 |
+
detalle = f"La URL ingresada no existe o fue movida: {err_str}"
|
| 122 |
+
sugiere = "Verifica que la URL sea correcta e incluya https://"
|
| 123 |
+
elif "runtime" in err_str.lower() or "tensor" in err_str.lower():
|
| 124 |
+
tipo = "modelo"
|
| 125 |
+
titulo = "Error en el modelo NLP"
|
| 126 |
+
detalle = "Ocurrió un error interno al procesar el texto con los modelos de lenguaje."
|
| 127 |
+
sugiere = "Intenta de nuevo. Si el error persiste, el texto extraído puede ser demasiado corto o inusual."
|
| 128 |
+
else:
|
| 129 |
+
tipo = "desconocido"
|
| 130 |
+
titulo = "Error inesperado"
|
| 131 |
+
detalle = err_str
|
| 132 |
+
sugiere = "Intenta de nuevo o prueba con un sitio web diferente."
|
| 133 |
+
|
| 134 |
+
with _lock:
|
| 135 |
+
_estado["error"] = {
|
| 136 |
+
"tipo": tipo, "titulo": titulo,
|
| 137 |
+
"detalle": detalle, "sugerencia": sugiere,
|
| 138 |
+
}
|
| 139 |
+
_estado["paso"] = -1
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# ── Thread de animación de porcentaje ─────────────────────────────────────────
|
| 143 |
+
|
| 144 |
+
def _animar_porcentaje() -> None:
|
| 145 |
+
"""Incrementa el % gradualmente dentro del techo de cada paso."""
|
| 146 |
+
global _estado
|
| 147 |
+
while True:
|
| 148 |
+
time.sleep(0.8)
|
| 149 |
+
with _lock:
|
| 150 |
+
paso = _estado["paso"]
|
| 151 |
+
if paso <= 0 or paso == 4:
|
| 152 |
+
break
|
| 153 |
+
techo = _PCT_TECHO.get(paso, 95)
|
| 154 |
+
pct = _estado["pct"]
|
| 155 |
+
if pct < techo:
|
| 156 |
+
# Avance más rápido al principio, más lento cerca del techo
|
| 157 |
+
incremento = max(1, int((techo - pct) * 0.07))
|
| 158 |
+
_estado["pct"] = min(pct + incremento, techo)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
# ── Componente visual de pasos ────────────────────────────────────────────────
|
| 162 |
+
|
| 163 |
+
def _pasos_html(paso: int) -> html.Div:
|
| 164 |
+
items = []
|
| 165 |
+
for i, label in enumerate(PASOS_LABELS, start=1):
|
| 166 |
+
if paso == -1:
|
| 167 |
+
estado = "error" if i == 1 else "pendiente"
|
| 168 |
+
elif i < paso:
|
| 169 |
+
estado = "completado"
|
| 170 |
+
elif i == paso:
|
| 171 |
+
estado = "activo"
|
| 172 |
+
else:
|
| 173 |
+
estado = "pendiente"
|
| 174 |
+
|
| 175 |
+
color = {
|
| 176 |
+
"completado": "#1e6b45", # verde bosque
|
| 177 |
+
"activo": "#1c3160", # azul marino
|
| 178 |
+
"pendiente": "#6b7a96", # gris medio visible en claro
|
| 179 |
+
"error": "#8b1c1c", # rojo ladrillo
|
| 180 |
+
}[estado]
|
| 181 |
+
icono = "✔" if estado == "completado" else ("✖" if estado == "error" else str(i))
|
| 182 |
+
|
| 183 |
+
items.append(html.Div(
|
| 184 |
+
style={"display": "flex", "flexDirection": "column",
|
| 185 |
+
"alignItems": "center", "gap": "5px", "flex": "1"},
|
| 186 |
+
children=[
|
| 187 |
+
html.Div(icono, style={
|
| 188 |
+
"width": "32px", "height": "32px", "borderRadius": "50%",
|
| 189 |
+
"border": f"2px solid {color}", "color": color,
|
| 190 |
+
"display": "flex", "alignItems": "center", "justifyContent": "center",
|
| 191 |
+
"fontWeight": "700", "fontSize": "0.8rem",
|
| 192 |
+
"background": "rgba(28,49,96,0.1)" if estado == "activo" else "transparent",
|
| 193 |
+
"boxShadow": "0 0 0 4px rgba(28,49,96,0.12)" if estado == "activo" else "none",
|
| 194 |
+
}),
|
| 195 |
+
html.Span(label, style={
|
| 196 |
+
"fontSize": "0.68rem", "color": color, "fontWeight": "600",
|
| 197 |
+
}),
|
| 198 |
+
],
|
| 199 |
+
))
|
| 200 |
+
if i < len(PASOS_LABELS):
|
| 201 |
+
items.append(html.Div(style={
|
| 202 |
+
"flex": "2", "height": "2px", "marginTop": "-17px",
|
| 203 |
+
"background": "#1e6b45" if i < paso else "#b0bacb",
|
| 204 |
+
"transition": "background 0.5s",
|
| 205 |
+
}))
|
| 206 |
+
|
| 207 |
+
desc_color = "#f87171" if paso == -1 else "#8892a4"
|
| 208 |
+
desc = ("⚠ Error durante el análisis" if paso == -1
|
| 209 |
+
else PASOS_DETALLE[paso - 1] if 1 <= paso <= 4
|
| 210 |
+
else "Iniciando...")
|
| 211 |
+
|
| 212 |
+
return html.Div([
|
| 213 |
+
html.Div(items, style={
|
| 214 |
+
"display": "flex", "alignItems": "center",
|
| 215 |
+
"padding": "4px 0 10px", "gap": "2px",
|
| 216 |
+
}),
|
| 217 |
+
html.P(desc, style={
|
| 218 |
+
"color": desc_color, "fontSize": "0.75rem",
|
| 219 |
+
"margin": "0", "textAlign": "center",
|
| 220 |
+
}),
|
| 221 |
+
])
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
# ── Registro de callbacks ─────────────────────────────────────────────────────
|
| 225 |
+
|
| 226 |
+
def registrar_callbacks(app: dash.Dash) -> None:
|
| 227 |
+
|
| 228 |
+
# 1. Botón Analizar → lanza threads, muestra panel
|
| 229 |
+
@app.callback(
|
| 230 |
+
Output("interval_progress", "disabled"),
|
| 231 |
+
Output("panel-progreso", "style"),
|
| 232 |
+
Output("error-msg", "children"),
|
| 233 |
+
Input({"type": "btn", "index": ALL}, "n_clicks"),
|
| 234 |
+
State("store_name", "data"),
|
| 235 |
+
State("store_link", "data"),
|
| 236 |
+
prevent_initial_call=True,
|
| 237 |
+
)
|
| 238 |
+
def iniciar_analisis(n_clicks, nombre, link):
|
| 239 |
+
global _estado
|
| 240 |
+
if not ctx.triggered_id or not ctx.triggered[0]["value"]:
|
| 241 |
+
return dash.no_update, dash.no_update, ""
|
| 242 |
+
tid = ctx.triggered_id
|
| 243 |
+
if not (isinstance(tid, dict) and tid.get("type") == "btn"):
|
| 244 |
+
return dash.no_update, dash.no_update, ""
|
| 245 |
+
if tid.get("index") != 0:
|
| 246 |
+
return True, _PANEL_OCULTO, ""
|
| 247 |
+
if not link:
|
| 248 |
+
return True, _PANEL_VISIBLE, html.Div(
|
| 249 |
+
"⚠ Por favor ingresa el link del sitio web.",
|
| 250 |
+
style={"color": "#f87171", "fontSize": "0.82rem", "marginTop": "8px"},
|
| 251 |
+
)
|
| 252 |
+
with _lock:
|
| 253 |
+
_estado = {"paso": 0, "pct": 0, "perfil": None, "error": None}
|
| 254 |
+
|
| 255 |
+
threading.Thread(target=_correr_analisis,
|
| 256 |
+
args=(link, nombre or "Organización"), daemon=True).start()
|
| 257 |
+
threading.Thread(target=_animar_porcentaje, daemon=True).start()
|
| 258 |
+
|
| 259 |
+
return False, _PANEL_VISIBLE, ""
|
| 260 |
+
|
| 261 |
+
# 2. Botones volver al inicio
|
| 262 |
+
@app.callback(
|
| 263 |
+
Output("url", "pathname"),
|
| 264 |
+
Input({"type": "btn", "index": ALL}, "n_clicks"),
|
| 265 |
+
prevent_initial_call=True,
|
| 266 |
+
)
|
| 267 |
+
def volver_inicio(n_clicks):
|
| 268 |
+
if not ctx.triggered_id or not ctx.triggered[0]["value"]:
|
| 269 |
+
return dash.no_update
|
| 270 |
+
tid = ctx.triggered_id
|
| 271 |
+
if isinstance(tid, dict) and tid.get("type") == "btn" and tid.get("index") != 0:
|
| 272 |
+
return "/"
|
| 273 |
+
return dash.no_update
|
| 274 |
+
|
| 275 |
+
# 3. Polling → actualiza barra %, pasos, y redirige al terminar
|
| 276 |
+
@app.callback(
|
| 277 |
+
Output("progreso-container", "children"),
|
| 278 |
+
Output("progreso-bar", "style"),
|
| 279 |
+
Output("progreso-pct", "children"),
|
| 280 |
+
Output("url", "pathname", allow_duplicate=True),
|
| 281 |
+
Output("interval_progress", "disabled", allow_duplicate=True),
|
| 282 |
+
Output("panel-progreso", "style", allow_duplicate=True),
|
| 283 |
+
Input("interval_progress", "n_intervals"),
|
| 284 |
+
prevent_initial_call=True,
|
| 285 |
+
)
|
| 286 |
+
def actualizar_progreso(n):
|
| 287 |
+
with _lock:
|
| 288 |
+
e = dict(_estado)
|
| 289 |
+
|
| 290 |
+
paso = e["paso"]
|
| 291 |
+
pct = e.get("pct", 0)
|
| 292 |
+
|
| 293 |
+
bar_style = {
|
| 294 |
+
"height": "100%", "borderRadius": "999px",
|
| 295 |
+
"background": "linear-gradient(90deg, #b87a2a, #d4a843)",
|
| 296 |
+
"width": f"{pct}%",
|
| 297 |
+
"transition": "width 0.8s ease",
|
| 298 |
+
}
|
| 299 |
+
pct_txt = f"{pct}%"
|
| 300 |
+
|
| 301 |
+
# Error — mostrar panel descriptivo
|
| 302 |
+
if paso == -1:
|
| 303 |
+
err = e.get("error", {})
|
| 304 |
+
if isinstance(err, dict):
|
| 305 |
+
titulo = err.get("titulo", "Error durante el análisis")
|
| 306 |
+
detalle = err.get("detalle", "Ocurrió un problema inesperado.")
|
| 307 |
+
sugiere = err.get("sugerencia", "")
|
| 308 |
+
tipo = err.get("tipo", "desconocido")
|
| 309 |
+
else:
|
| 310 |
+
titulo, detalle, sugiere, tipo = str(err), "", "", "desconocido"
|
| 311 |
+
|
| 312 |
+
iconos = {
|
| 313 |
+
"red": "🌐", "ssl": "🔒", "acceso": "🚫",
|
| 314 |
+
"url": "🔗", "modelo": "🤖", "extraccion": "📄",
|
| 315 |
+
"desconocido": "⚠",
|
| 316 |
+
}
|
| 317 |
+
icono = iconos.get(tipo, "⚠")
|
| 318 |
+
|
| 319 |
+
panel_error = html.Div(
|
| 320 |
+
style={
|
| 321 |
+
"marginTop": "12px",
|
| 322 |
+
"background": "#fdeaea",
|
| 323 |
+
"border": "1.5px solid #d48585",
|
| 324 |
+
"borderRadius": "8px",
|
| 325 |
+
"padding": "14px 16px",
|
| 326 |
+
},
|
| 327 |
+
children=[
|
| 328 |
+
html.Div(
|
| 329 |
+
style={"display": "flex", "alignItems": "center",
|
| 330 |
+
"gap": "8px", "marginBottom": "6px"},
|
| 331 |
+
children=[
|
| 332 |
+
html.Span(icono, style={"fontSize": "1.1rem"}),
|
| 333 |
+
html.Span(titulo, style={
|
| 334 |
+
"fontWeight": "700", "color": "#8b1c1c",
|
| 335 |
+
"fontSize": "0.95rem",
|
| 336 |
+
}),
|
| 337 |
+
],
|
| 338 |
+
),
|
| 339 |
+
html.P(detalle, style={
|
| 340 |
+
"color": "#6b2020", "fontSize": "0.9rem",
|
| 341 |
+
"margin": "0 0 6px", "lineHeight": "1.5",
|
| 342 |
+
}),
|
| 343 |
+
html.P(f"💡 {sugiere}", style={
|
| 344 |
+
"color": "#3d4a60", "fontSize": "0.88rem",
|
| 345 |
+
"margin": "0", "fontStyle": "italic",
|
| 346 |
+
}) if sugiere else None,
|
| 347 |
+
html.Button(
|
| 348 |
+
"Intentar de nuevo",
|
| 349 |
+
style={
|
| 350 |
+
"marginTop": "12px", "padding": "10px 20px",
|
| 351 |
+
"background": "#fff", "border": "2px solid #8b1c1c",
|
| 352 |
+
"borderRadius": "8px", "color": "#8b1c1c",
|
| 353 |
+
"cursor": "pointer", "fontSize": "0.95rem",
|
| 354 |
+
"fontWeight": "700", "fontFamily": "inherit",
|
| 355 |
+
},
|
| 356 |
+
id={"type": "btn", "index": 0},
|
| 357 |
+
n_clicks=0,
|
| 358 |
+
),
|
| 359 |
+
],
|
| 360 |
+
)
|
| 361 |
+
return (
|
| 362 |
+
html.Div([_pasos_html(-1), panel_error]),
|
| 363 |
+
{**bar_style, "background": "#8b1c1c", "width": "100%", "boxShadow": "0 1px 4px rgba(139,28,28,0.4)"},
|
| 364 |
+
"Error",
|
| 365 |
+
dash.no_update, True, _PANEL_VISIBLE,
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
# Completado
|
| 369 |
+
if paso == 4 and e["perfil"]:
|
| 370 |
+
return (
|
| 371 |
+
_pasos_html(4),
|
| 372 |
+
{**bar_style, "width": "100%"},
|
| 373 |
+
"100%",
|
| 374 |
+
f"/profile_{e['perfil']}", True, _PANEL_OCULTO,
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
# En progreso
|
| 378 |
+
return _pasos_html(paso), bar_style, pct_txt, dash.no_update, dash.no_update, dash.no_update
|
| 379 |
+
|
| 380 |
+
# 4. Guardar datos en sesión
|
| 381 |
+
@app.callback(
|
| 382 |
+
Output("store_name", "data"),
|
| 383 |
+
Output("store_link", "data"),
|
| 384 |
+
Input("Nombre_org", "value"),
|
| 385 |
+
Input("Link_org", "value"),
|
| 386 |
+
prevent_initial_call=True,
|
| 387 |
+
)
|
| 388 |
+
def guardar_datos(nombre, link):
|
| 389 |
+
return nombre, link
|
| 390 |
+
|
| 391 |
+
# 5. Abrir modal Venn al hacer clic en la imagen
|
| 392 |
+
@app.callback(
|
| 393 |
+
Output({"type": "venn-modal", "index": ALL}, "style"),
|
| 394 |
+
Input({"type": "venn-thumb", "index": ALL}, "n_clicks"),
|
| 395 |
+
Input({"type": "venn-close", "index": ALL}, "n_clicks"),
|
| 396 |
+
prevent_initial_call=True,
|
| 397 |
+
)
|
| 398 |
+
def toggle_venn_modal(thumb_clicks, close_clicks):
|
| 399 |
+
_ABIERTO = {"display": "flex"}
|
| 400 |
+
_CERRADO = {"display": "none"}
|
| 401 |
+
|
| 402 |
+
if not ctx.triggered_id:
|
| 403 |
+
return [_CERRADO] * len(thumb_clicks)
|
| 404 |
+
|
| 405 |
+
tid = ctx.triggered_id
|
| 406 |
+
tipo = tid.get("type")
|
| 407 |
+
idx = tid.get("index")
|
| 408 |
+
|
| 409 |
+
# Obtener cuántos perfiles hay
|
| 410 |
+
n = len(thumb_clicks)
|
| 411 |
+
result = [_CERRADO] * n
|
| 412 |
+
|
| 413 |
+
if tipo == "venn-thumb":
|
| 414 |
+
# Abrir el modal del perfil clicado (índice = número de perfil 1-5)
|
| 415 |
+
for j in range(n):
|
| 416 |
+
# Los índices son 1..5, la lista es 0..4
|
| 417 |
+
if j + 1 == idx:
|
| 418 |
+
result[j] = _ABIERTO
|
| 419 |
+
# Si es venn-close, todos quedan cerrados (result ya es todo _CERRADO)
|
| 420 |
+
|
| 421 |
+
return result
|
data/__init__.py
ADDED
|
File without changes
|
data/definitions.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Definiciones, constantes y datos estáticos de la aplicación AIM Dashboard.
|
| 3 |
+
Separado del código lógico para facilitar mantenimiento y localización.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
DEFINICION_TRIADA_AIM = """
|
| 7 |
+
**La Tríada AIM (Awareness, Infrastructure, Management)** no es un nuevo modelo de madurez
|
| 8 |
+
en ciberseguridad, sino una estrategia de priorización diseñada para guiar a las organizaciones.
|
| 9 |
+
|
| 10 |
+
A través de nuestro servicio obtenemos el perfil correspondiente a su empresa a partir del
|
| 11 |
+
link de su sitio web, para dar indicaciones acerca de qué estrategias de ciberseguridad
|
| 12 |
+
serían adecuadas para su organización.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
DEFINICIONES_PERFILES = [
|
| 16 |
+
# ── Perfil 1 ── Gestión formalizada, visibilidad técnica limitada
|
| 17 |
+
"""
|
| 18 |
+
**Perfil 1 — El Gestor Formalizado**
|
| 19 |
+
|
| 20 |
+
Su organización ha construido una base sólida en la dimensión de **Gestión**: cuenta con
|
| 21 |
+
políticas documentadas, conciencia sobre obligaciones legales y una estructura básica para
|
| 22 |
+
administrar activos y personal de seguridad. Entiende *qué* debe proteger y *quién* es
|
| 23 |
+
responsable de hacerlo.
|
| 24 |
+
|
| 25 |
+
Sin embargo, la brecha crítica está en la **visibilidad técnica**: carece de capacidad para
|
| 26 |
+
detectar amenazas en tiempo real, su arquitectura de red no está diseñada pensando en
|
| 27 |
+
seguridad y los vectores de ataque técnicos permanecen sin monitoreo activo.
|
| 28 |
+
|
| 29 |
+
**¿Qué significa esto en la práctica?**
|
| 30 |
+
Su empresa sabe que existe el riesgo, tiene los papeles en orden, pero no vería un ataque
|
| 31 |
+
en curso hasta que ya causara daño. Es como tener un buen seguro pero ninguna alarma.
|
| 32 |
+
|
| 33 |
+
**Próximos pasos recomendados:**
|
| 34 |
+
Priorizar la implementación de herramientas de monitoreo (SIEM básico o EDR), revisar la
|
| 35 |
+
segmentación de red y realizar un primer ejercicio de análisis de vulnerabilidades.
|
| 36 |
+
""",
|
| 37 |
+
# ── Perfil 2 ── Alta madurez técnica y de conciencia, gestión en desarrollo
|
| 38 |
+
"""
|
| 39 |
+
**Perfil 2 — El Técnico Avanzado**
|
| 40 |
+
|
| 41 |
+
Su organización demuestra un nivel de madurez **excepcionalmente alto** en las dimensiones
|
| 42 |
+
técnicas: tiene tecnología de seguridad bien implementada, arquitectura defensiva estructurada
|
| 43 |
+
y una visibilidad del entorno de amenazas muy por encima del promedio para una PyME.
|
| 44 |
+
|
| 45 |
+
Además posee una cultura de seguridad activa y capacidad de detección y respuesta ante
|
| 46 |
+
incidentes. En términos del modelo AIM, cubre prácticamente todo el espectro de
|
| 47 |
+
**Awareness** e **Infrastructure**.
|
| 48 |
+
|
| 49 |
+
La oportunidad de mejora está en la **formalización de la gestión**: los procesos existen
|
| 50 |
+
pero dependen de personas clave, la gestión del talento de seguridad no está sistematizada
|
| 51 |
+
y el cumplimiento normativo puede estar rezagado frente al nivel técnico alcanzado.
|
| 52 |
+
|
| 53 |
+
**Próximos pasos recomendados:**
|
| 54 |
+
Documentar y transferir el conocimiento tácito a procesos formales, revisar brechas de
|
| 55 |
+
cumplimiento regulatorio y estructurar un plan de sucesión para roles críticos de seguridad.
|
| 56 |
+
""",
|
| 57 |
+
# ── Perfil 3 ── Operaciones robustas, estrategia y cultura por desarrollar
|
| 58 |
+
"""
|
| 59 |
+
**Perfil 3 — El Operador Robusto**
|
| 60 |
+
|
| 61 |
+
Su organización ha madurado en la ejecución operativa de la seguridad: administra
|
| 62 |
+
correctamente sus activos, mantiene su fuerza laboral alineada con las responsabilidades
|
| 63 |
+
de seguridad y ha desarrollado capacidades para identificar y mitigar vulnerabilidades técnicas.
|
| 64 |
+
|
| 65 |
+
Existe un programa de seguridad que funciona en el día a día. La gestión del riesgo tiene
|
| 66 |
+
presencia y la gestión del conocimiento es un punto diferenciador positivo.
|
| 67 |
+
|
| 68 |
+
Las brechas se concentran en la **dirección estratégica y la cultura**: no existe una
|
| 69 |
+
política de seguridad que unifique los esfuerzos, la seguridad no está integrada como
|
| 70 |
+
valor organizacional y el cumplimiento normativo no ha sido abordado formalmente.
|
| 71 |
+
|
| 72 |
+
**Próximos pasos recomendados:**
|
| 73 |
+
Desarrollar una política de seguridad corporativa aprobada por la dirección, iniciar un
|
| 74 |
+
programa de cultura de seguridad para el personal no técnico y mapear los requisitos
|
| 75 |
+
regulatorios aplicables al sector.
|
| 76 |
+
""",
|
| 77 |
+
# ── Perfil 4 ── Cultura y conciencia desarrolladas, infraestructura rezagada
|
| 78 |
+
"""
|
| 79 |
+
**Perfil 4 — El Consciente Estratégico**
|
| 80 |
+
|
| 81 |
+
Su organización tiene algo valioso y difícil de construir: una **cultura de seguridad genuina**
|
| 82 |
+
y capacidad para detectar y responder a incidentes. El personal entiende los riesgos,
|
| 83 |
+
existe conciencia situacional y la gestión del conocimiento en seguridad es un activo real.
|
| 84 |
+
|
| 85 |
+
Esto la ubica por delante de la mayoría de las PyMEs, donde la mayor vulnerabilidad
|
| 86 |
+
es precisamente el factor humano.
|
| 87 |
+
|
| 88 |
+
La brecha está en la **infraestructura técnica**: la arquitectura no refleja el nivel de
|
| 89 |
+
madurez cultural alcanzado, los vectores de ataque técnico no están completamente
|
| 90 |
+
mitigados y el marco normativo-legal no ha sido formalizado.
|
| 91 |
+
|
| 92 |
+
**Próximos pasos recomendados:**
|
| 93 |
+
Traducir la cultura de seguridad existente en controles técnicos concretos: segmentación
|
| 94 |
+
de red, gestión de vulnerabilidades y hardening de sistemas. Aprovechar la conciencia
|
| 95 |
+
del equipo para acelerar la adopción de nuevas herramientas.
|
| 96 |
+
""",
|
| 97 |
+
# ── Perfil 5 ── Infraestructura y gestión sólidas, detección y cultura incipientes
|
| 98 |
+
"""
|
| 99 |
+
**Perfil 5 — El Arquitecto Estructurado**
|
| 100 |
+
|
| 101 |
+
Su organización ha invertido en construir una **base técnica y de gestión coherente**:
|
| 102 |
+
la arquitectura de seguridad está diseñada defensivamente, los activos están bajo control,
|
| 103 |
+
existe una estrategia de seguridad formal y se gestiona el riesgo con criterios definidos.
|
| 104 |
+
|
| 105 |
+
Es una organización que "construyó bien": su infraestructura digital refleja decisiones
|
| 106 |
+
de diseño seguro y los procesos de gestión respaldan esas decisiones.
|
| 107 |
+
|
| 108 |
+
Las áreas de mejora están en la **capacidad de detección activa y el factor humano**:
|
| 109 |
+
aún no se ha desarrollado plenamente la capacidad para detectar y responder a incidentes
|
| 110 |
+
en tiempo real, y la seguridad como valor cultural todavía no está arraigada en el
|
| 111 |
+
comportamiento cotidiano del personal.
|
| 112 |
+
|
| 113 |
+
**Próximos pasos recomendados:**
|
| 114 |
+
Implementar capacidades de detección y respuesta (SOC básico o servicio MDR), desarrollar
|
| 115 |
+
un programa de concientización continua para el personal y establecer ejercicios de
|
| 116 |
+
simulación de incidentes (tabletop exercises).
|
| 117 |
+
"""]
|
| 118 |
+
|
| 119 |
+
DOMINIOS_DEFINICIONES = {
|
| 120 |
+
"Cultura y Sociedad": (
|
| 121 |
+
"Refleja los valores, creencias y comportamientos del equipo frente a la seguridad. "
|
| 122 |
+
"Una cultura madura convierte a cada persona en un sensor activo de riesgos, donde "
|
| 123 |
+
"reportar incidentes se ve como una responsabilidad compartida y no como una amenaza."
|
| 124 |
+
),
|
| 125 |
+
"Conciencia Situacional": (
|
| 126 |
+
"Capacidad de detectar, correlacionar y comprender eventos de seguridad en tiempo real. "
|
| 127 |
+
"Incluye monitoreo centralizado (SIEM/XDR), inteligencia de amenazas y dashboards que "
|
| 128 |
+
"traducen datos técnicos en información útil para la toma de decisiones."
|
| 129 |
+
),
|
| 130 |
+
"Estándares y Tecnología": (
|
| 131 |
+
"Adopción y operación disciplinada de estándares (NIST, ISO 27001, CIS) y herramientas "
|
| 132 |
+
"de seguridad. No basta con comprar tecnología: este dominio mide si está correctamente "
|
| 133 |
+
"configurada, integrada y mantenida."
|
| 134 |
+
),
|
| 135 |
+
"Arquitectura": (
|
| 136 |
+
"Diseño estructural de la infraestructura digital pensado para minimizar el impacto de "
|
| 137 |
+
"una brecha. Incluye segmentación de red, modelos Zero Trust, zonas desmilitarizadas (DMZ) "
|
| 138 |
+
"y principios de mínimo privilegio aplicados desde el diseño."
|
| 139 |
+
),
|
| 140 |
+
"Amenazas y Vulnerabilidades": (
|
| 141 |
+
"Ciclo de vida completo de identificación y mitigación de vulnerabilidades: desde "
|
| 142 |
+
"escaneos automáticos y pruebas de penetración hasta la gestión priorizada de parches "
|
| 143 |
+
"según el riesgo real para el negocio."
|
| 144 |
+
),
|
| 145 |
+
"Programa": (
|
| 146 |
+
"Existencia de un programa formal de ciberseguridad con objetivos, presupuesto, "
|
| 147 |
+
"métricas y hoja de ruta. Asegura que las iniciativas de seguridad sean planificadas "
|
| 148 |
+
"y ejecutadas como un esfuerzo coherente y sostenido."
|
| 149 |
+
),
|
| 150 |
+
"Capital Humano": (
|
| 151 |
+
"Gestión del talento de seguridad: roles definidos, responsabilidades claras, "
|
| 152 |
+
"capacitación continua y planificación de sucesión. Cubre tanto al equipo técnico "
|
| 153 |
+
"especializado como al personal general con obligaciones de seguridad."
|
| 154 |
+
),
|
| 155 |
+
"Activos y Configuración": (
|
| 156 |
+
"Inventario actualizado de todos los activos digitales y físicos, con control de "
|
| 157 |
+
"cambios que previene modificaciones no autorizadas o inseguras. Sin saber qué "
|
| 158 |
+
"activos existen, es imposible protegerlos."
|
| 159 |
+
),
|
| 160 |
+
"Marco Legal y Regulatorio": (
|
| 161 |
+
"Cumplimiento de leyes, regulaciones sectoriales y contratos que imponen obligaciones "
|
| 162 |
+
"de seguridad (ej. Ley 21.663 en Chile, GDPR si hay datos europeos). Implica mapear "
|
| 163 |
+
"los requisitos aplicables y traducirlos en controles operacionales."
|
| 164 |
+
),
|
| 165 |
+
"Marco Legal y Regulatorio": (
|
| 166 |
+
"Cumplimiento de leyes, regulaciones sectoriales y contratos que imponen obligaciones "
|
| 167 |
+
"de seguridad. Implica mapear los requisitos aplicables y traducirlos en controles "
|
| 168 |
+
"operacionales concretos y auditables."
|
| 169 |
+
),
|
| 170 |
+
"Detección y Respuesta": (
|
| 171 |
+
"Capacidad de detectar, contener, erradicar y recuperarse de incidentes de seguridad "
|
| 172 |
+
"de forma estructurada. Incluye playbooks de respuesta, equipos con roles definidos "
|
| 173 |
+
"y ejercicios de simulación que validan la preparación real."
|
| 174 |
+
),
|
| 175 |
+
"Política y Estrategia": (
|
| 176 |
+
"Marco de políticas formales aprobadas por la dirección que definen el comportamiento "
|
| 177 |
+
"esperado, los controles requeridos y la estrategia de seguridad a mediano y largo "
|
| 178 |
+
"plazo. Proporciona el 'norte' que orienta todas las demás decisiones."
|
| 179 |
+
),
|
| 180 |
+
"Conocimiento y Capacidades": (
|
| 181 |
+
"Base de conocimiento institucional en ciberseguridad: inteligencia de amenazas, "
|
| 182 |
+
"lecciones aprendidas, competencias técnicas especializadas y capacidad de investigación. "
|
| 183 |
+
"Permite anticipar tendencias y no solo reaccionar a ellas."
|
| 184 |
+
),
|
| 185 |
+
"Riesgo": (
|
| 186 |
+
"Proceso sistemático para identificar, evaluar y priorizar riesgos según su probabilidad "
|
| 187 |
+
"e impacto en el negocio. Un registro de riesgos activo permite tomar decisiones de "
|
| 188 |
+
"inversión en seguridad basadas en evidencia, no en intuición."
|
| 189 |
+
),
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
# Dominios débiles (áreas a mejorar) por perfil (índice 0 = Perfil 1)
|
| 193 |
+
CONSEJOS_PERFILES_NEGATIVOS = [
|
| 194 |
+
["Conciencia Situacional", "Arquitectura", "Amenazas y Vulnerabilidades"],
|
| 195 |
+
["Marco Legal y Regulatorio", "Capital Humano", "Activos y Configuración"],
|
| 196 |
+
["Política y Estrategia", "Cultura y Sociedad", "Marco Legal y Regulatorio"],
|
| 197 |
+
["Arquitectura", "Amenazas y Vulnerabilidades", "Marco Legal y Regulatorio"],
|
| 198 |
+
["Detección y Respuesta", "Cultura y Sociedad", "Conciencia Situacional"],
|
| 199 |
+
]
|
| 200 |
+
|
| 201 |
+
# Dominios fuertes (fortalezas) por perfil
|
| 202 |
+
CONSEJOS_PERFILES_POSITIVO = [
|
| 203 |
+
["Marco Legal y Regulatorio", "Capital Humano", "Activos y Configuración"],
|
| 204 |
+
["Estándares y Tecnología", "Conciencia Situacional", "Arquitectura"],
|
| 205 |
+
["Programa", "Amenazas y Vulnerabilidades", "Capital Humano"],
|
| 206 |
+
["Detección y Respuesta", "Cultura y Sociedad", "Conciencia Situacional"],
|
| 207 |
+
["Arquitectura", "Amenazas y Vulnerabilidades", "Activos y Configuración"],
|
| 208 |
+
]
|
| 209 |
+
|
| 210 |
+
# Fortalezas completas por perfil (para el diagrama Venn)
|
| 211 |
+
FORTALEZAS_PERFIL = [
|
| 212 |
+
["Política y Estrategia", "Riesgo", "Programa", "Detección y Respuesta",
|
| 213 |
+
"Marco Legal y Regulatorio", "Activos y Configuración", "Capital Humano"],
|
| 214 |
+
["Política y Estrategia", "Conocimiento y Capacidades", "Detección y Respuesta",
|
| 215 |
+
"Estándares y Tecnología", "Arquitectura", "Amenazas y Vulnerabilidades",
|
| 216 |
+
"Cultura y Sociedad", "Conciencia Situacional"],
|
| 217 |
+
["Conocimiento y Capacidades", "Riesgo", "Programa", "Activos y Configuración",
|
| 218 |
+
"Capital Humano", "Amenazas y Vulnerabilidades"],
|
| 219 |
+
["Conocimiento y Capacidades", "Riesgo", "Detección y Respuesta",
|
| 220 |
+
"Cultura y Sociedad", "Conciencia Situacional"],
|
| 221 |
+
["Política y Estrategia", "Riesgo", "Programa", "Marco Legal y Regulatorio",
|
| 222 |
+
"Activos y Configuración", "Arquitectura", "Amenazas y Vulnerabilidades"],
|
| 223 |
+
]
|
| 224 |
+
|
| 225 |
+
# --- Diagrama Venn ---
|
| 226 |
+
COLORES_BASE = {
|
| 227 |
+
"Concientizacion": "#e06060",
|
| 228 |
+
"Infraestructura": "#4a8c5c",
|
| 229 |
+
"Gestion": "#3a6ab0",
|
| 230 |
+
"Bridge": "#c87e30",
|
| 231 |
+
"Core": "#8a6d3b",
|
| 232 |
+
"Desactivado": "#c8d0df",
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
VENN_SECTIONS = {
|
| 236 |
+
"100": "Concientizacion",
|
| 237 |
+
"010": "Infraestructura",
|
| 238 |
+
"001": "Gestion",
|
| 239 |
+
"110": "Bridge",
|
| 240 |
+
"101": "Bridge",
|
| 241 |
+
"011": "Bridge",
|
| 242 |
+
"111": "Core",
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
SUBCATEGORIAS = {
|
| 246 |
+
"A": ["Cultura y Sociedad", "Conciencia Situacional"],
|
| 247 |
+
"I": ["Arquitectura", "Amenazas y Vulnerabilidades"],
|
| 248 |
+
"M": ["Marco Legal y Regulatorio", "Activos y Configuración", "Capital Humano"],
|
| 249 |
+
"A-I": ["Estándares y Tecnología"],
|
| 250 |
+
"A-M": ["Detección y Respuesta"],
|
| 251 |
+
"I-M": ["Programa"],
|
| 252 |
+
"A-I-M": ["Política y Estrategia", "Conocimiento y Capacidades", "Riesgo"],
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
DOMINIOS_POR_ETAPA = {
|
| 256 |
+
"Core": SUBCATEGORIAS["A-I-M"],
|
| 257 |
+
"Bridge": SUBCATEGORIAS["A-I"] + SUBCATEGORIAS["A-M"] + SUBCATEGORIAS["I-M"],
|
| 258 |
+
"Concientización": SUBCATEGORIAS["A"],
|
| 259 |
+
"Infraestructura": SUBCATEGORIAS["I"],
|
| 260 |
+
"Gestión": SUBCATEGORIAS["M"],
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
# Mapeo de zonas Venn a subcategorías
|
| 264 |
+
VENN_ID_TO_SUBCATEGORIA = {
|
| 265 |
+
"100": SUBCATEGORIAS["A"],
|
| 266 |
+
"010": SUBCATEGORIAS["I"],
|
| 267 |
+
"001": SUBCATEGORIAS["M"],
|
| 268 |
+
"110": SUBCATEGORIAS["A-I"],
|
| 269 |
+
"101": SUBCATEGORIAS["A-M"],
|
| 270 |
+
"011": SUBCATEGORIAS["I-M"],
|
| 271 |
+
"111": SUBCATEGORIAS["A-I-M"],
|
| 272 |
+
}
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: "3.9"
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
aim-dashboard:
|
| 5 |
+
build: .
|
| 6 |
+
container_name: aim_dashboard
|
| 7 |
+
ports:
|
| 8 |
+
- "8050:8050" # Acceso en http://localhost:8050
|
| 9 |
+
volumes:
|
| 10 |
+
# Monta el modelo externo para no tener que reconstruir la imagen
|
| 11 |
+
# si actualizas el modelo K-Means
|
| 12 |
+
- ./Modelo_Pymes.pkl:/app/Modelo_Pymes.pkl:ro
|
| 13 |
+
environment:
|
| 14 |
+
- PYTHONUNBUFFERED=1
|
| 15 |
+
restart: unless-stopped
|
| 16 |
+
# Límite de memoria recomendado (los modelos NLP son pesados)
|
| 17 |
+
mem_limit: 4g
|
| 18 |
+
|
| 19 |
+
# ── Opcional: Nginx como proxy reverso (descomentar para producción) ──────
|
| 20 |
+
# nginx:
|
| 21 |
+
# image: nginx:alpine
|
| 22 |
+
# container_name: aim_nginx
|
| 23 |
+
# ports:
|
| 24 |
+
# - "80:80"
|
| 25 |
+
# - "443:443"
|
| 26 |
+
# volumes:
|
| 27 |
+
# - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
| 28 |
+
# depends_on:
|
| 29 |
+
# - aim-dashboard
|
| 30 |
+
# restart: unless-stopped
|
layouts/__init__.py
ADDED
|
File without changes
|
layouts/pages.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Módulo de layouts del dashboard AIM.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from dash import dcc, html
|
| 6 |
+
|
| 7 |
+
from data.definitions import (
|
| 8 |
+
DEFINICION_TRIADA_AIM,
|
| 9 |
+
DEFINICIONES_PERFILES,
|
| 10 |
+
DOMINIOS_DEFINICIONES,
|
| 11 |
+
CONSEJOS_PERFILES_NEGATIVOS,
|
| 12 |
+
CONSEJOS_PERFILES_POSITIVO,
|
| 13 |
+
FORTALEZAS_PERFIL,
|
| 14 |
+
)
|
| 15 |
+
from logic.venn import VENN_IMG_COMPLETO, generar_venn_base
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ---------- Pantalla de inicio ------------------------------------------------
|
| 20 |
+
|
| 21 |
+
def crear_layout_home() -> html.Div:
|
| 22 |
+
return html.Div(
|
| 23 |
+
className="aim-container",
|
| 24 |
+
children=[
|
| 25 |
+
html.Div(className="aim-header", children=[
|
| 26 |
+
html.H1("Tríada AIM"),
|
| 27 |
+
html.P("Perfil de ciberseguridad para PyMEs", className="subtitle"),
|
| 28 |
+
]),
|
| 29 |
+
|
| 30 |
+
html.Div(className="aim-two-col", children=[
|
| 31 |
+
|
| 32 |
+
# ---- Columna izquierda ----
|
| 33 |
+
html.Div(className="col-left", children=[
|
| 34 |
+
html.Div(className="aim-card", children=[
|
| 35 |
+
html.Div("Ingrese sus datos", className="aim-card-label"),
|
| 36 |
+
html.Label("Nombre de la organización",
|
| 37 |
+
style={"fontWeight": "600", "marginBottom": "4px"}),
|
| 38 |
+
dcc.Input(
|
| 39 |
+
id="Nombre_org", type="text",
|
| 40 |
+
placeholder="Ej: Mi Empresa S.A.",
|
| 41 |
+
className="aim-input",
|
| 42 |
+
),
|
| 43 |
+
html.Label("Sitio web de la organización",
|
| 44 |
+
style={"fontWeight": "600", "marginBottom": "4px"}),
|
| 45 |
+
dcc.Input(
|
| 46 |
+
id="Link_org", type="text",
|
| 47 |
+
placeholder="https://www.miempresa.cl",
|
| 48 |
+
className="aim-input",
|
| 49 |
+
),
|
| 50 |
+
html.Button(
|
| 51 |
+
"Analizar perfil →",
|
| 52 |
+
id={"type": "btn", "index": 0},
|
| 53 |
+
n_clicks=0,
|
| 54 |
+
className="aim-btn-primary",
|
| 55 |
+
),
|
| 56 |
+
]),
|
| 57 |
+
|
| 58 |
+
# Descripción tríada
|
| 59 |
+
html.Div(className="aim-card", children=[
|
| 60 |
+
html.Div("¿Qué es la Tríada AIM?", className="aim-card-label"),
|
| 61 |
+
dcc.Markdown(DEFINICION_TRIADA_AIM, className="aim-definition"),
|
| 62 |
+
]),
|
| 63 |
+
]),
|
| 64 |
+
|
| 65 |
+
# ---- Columna derecha ----
|
| 66 |
+
html.Div(className="col-right", children=[
|
| 67 |
+
html.Div(className="aim-card", children=[
|
| 68 |
+
html.Div("Modelo de referencia", className="aim-card-label"),
|
| 69 |
+
html.Img(src=VENN_IMG_COMPLETO, className="venn-img"),
|
| 70 |
+
]),
|
| 71 |
+
]),
|
| 72 |
+
]),
|
| 73 |
+
],
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ---------- Pantalla de perfil ------------------------------------------------
|
| 78 |
+
|
| 79 |
+
def _tarjeta_dominio(nombre: str, tipo: str) -> html.Div:
|
| 80 |
+
"""Tarjeta acordeón con descripción completa al expandir."""
|
| 81 |
+
is_strength = tipo == "strength"
|
| 82 |
+
color = "var(--success)" if is_strength else "var(--danger)"
|
| 83 |
+
bg_color = "var(--success-bg)" if is_strength else "var(--danger-bg)"
|
| 84 |
+
border_col = "var(--success-border)" if is_strength else "var(--danger-border)"
|
| 85 |
+
icono = "✦" if is_strength else "◈"
|
| 86 |
+
desc = DOMINIOS_DEFINICIONES.get(nombre, "")
|
| 87 |
+
|
| 88 |
+
return html.Details(
|
| 89 |
+
style={
|
| 90 |
+
"background": bg_color,
|
| 91 |
+
"border": f"1.5px solid {border_col}",
|
| 92 |
+
"borderRadius": "8px",
|
| 93 |
+
"marginBottom": "10px",
|
| 94 |
+
"overflow": "hidden",
|
| 95 |
+
},
|
| 96 |
+
children=[
|
| 97 |
+
html.Summary(
|
| 98 |
+
style={
|
| 99 |
+
"padding": "14px 18px",
|
| 100 |
+
"cursor": "pointer",
|
| 101 |
+
"display": "flex",
|
| 102 |
+
"alignItems": "center",
|
| 103 |
+
"gap": "10px",
|
| 104 |
+
"fontWeight": "700",
|
| 105 |
+
"fontSize": "1rem",
|
| 106 |
+
"color": color,
|
| 107 |
+
"listStyle": "none",
|
| 108 |
+
"userSelect": "none",
|
| 109 |
+
},
|
| 110 |
+
children=[
|
| 111 |
+
html.Span(icono, style={"fontSize": "0.9rem", "flexShrink": "0"}),
|
| 112 |
+
html.Span(nombre, style={"flex": "1"}),
|
| 113 |
+
html.Span("▾ ver más", style={
|
| 114 |
+
"fontSize": "0.78rem",
|
| 115 |
+
"color": "var(--text-muted)",
|
| 116 |
+
"fontWeight": "500",
|
| 117 |
+
"whiteSpace": "nowrap",
|
| 118 |
+
}),
|
| 119 |
+
],
|
| 120 |
+
),
|
| 121 |
+
html.Div(
|
| 122 |
+
style={
|
| 123 |
+
"padding": "12px 18px 16px",
|
| 124 |
+
"borderTop": f"1.5px solid {border_col}",
|
| 125 |
+
"background": "#ffffff",
|
| 126 |
+
},
|
| 127 |
+
children=[
|
| 128 |
+
html.P(
|
| 129 |
+
desc,
|
| 130 |
+
style={
|
| 131 |
+
"margin": "0",
|
| 132 |
+
"fontSize": "0.98rem",
|
| 133 |
+
"color": "var(--text-secondary)",
|
| 134 |
+
"lineHeight": "1.75",
|
| 135 |
+
},
|
| 136 |
+
)
|
| 137 |
+
],
|
| 138 |
+
),
|
| 139 |
+
],
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _columna_dominios(titulo: str, icono: str, color: str,
|
| 144 |
+
dominios: list, tipo: str) -> html.Div:
|
| 145 |
+
return html.Div([
|
| 146 |
+
html.Div(
|
| 147 |
+
className="aim-domain-col-header",
|
| 148 |
+
style={"color": color, "borderBottomColor": color, "fontSize": "0.82rem"},
|
| 149 |
+
children=[
|
| 150 |
+
html.Span(icono, style={"fontSize": "1rem"}),
|
| 151 |
+
html.Span(titulo),
|
| 152 |
+
],
|
| 153 |
+
),
|
| 154 |
+
*[_tarjeta_dominio(d, tipo) for d in dominios],
|
| 155 |
+
])
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def crear_layout_perfil(i: int) -> html.Div:
|
| 159 |
+
idx = i - 1
|
| 160 |
+
venn_img = f"data:image/png;base64,{generar_venn_base('Reporte', FORTALEZAS_PERFIL[idx])}"
|
| 161 |
+
fortalezas = CONSEJOS_PERFILES_POSITIVO[idx]
|
| 162 |
+
debilidades = CONSEJOS_PERFILES_NEGATIVOS[idx]
|
| 163 |
+
|
| 164 |
+
return html.Div(
|
| 165 |
+
className="aim-container",
|
| 166 |
+
children=[
|
| 167 |
+
# Botón volver
|
| 168 |
+
html.Div(className="aim-nav-home", children=[
|
| 169 |
+
html.Button(
|
| 170 |
+
"← Inicio",
|
| 171 |
+
id={"type": "btn", "index": i},
|
| 172 |
+
n_clicks=0,
|
| 173 |
+
className="aim-btn-secondary",
|
| 174 |
+
style={"width": "auto", "padding": "8px 18px"},
|
| 175 |
+
)
|
| 176 |
+
]),
|
| 177 |
+
|
| 178 |
+
# Header
|
| 179 |
+
html.Div(className="aim-header", children=[
|
| 180 |
+
html.H1(f"Perfil {i}"),
|
| 181 |
+
html.P("Resultado del análisis de ciberseguridad AIM", className="subtitle"),
|
| 182 |
+
]),
|
| 183 |
+
|
| 184 |
+
# Descripción del perfil (ancho completo)
|
| 185 |
+
html.Div(className="aim-card", style={"marginBottom": "20px"}, children=[
|
| 186 |
+
html.Div(f"Perfil {i} — Descripción", className="aim-card-label"),
|
| 187 |
+
dcc.Markdown(DEFINICIONES_PERFILES[idx], className="aim-definition"),
|
| 188 |
+
]),
|
| 189 |
+
|
| 190 |
+
# Grid: fortalezas | debilidades | Venn (Venn más ancho)
|
| 191 |
+
html.Div(
|
| 192 |
+
style={
|
| 193 |
+
"display": "grid",
|
| 194 |
+
"gridTemplateColumns": "1fr 1fr 1.4fr",
|
| 195 |
+
"gap": "20px",
|
| 196 |
+
"alignItems": "start",
|
| 197 |
+
},
|
| 198 |
+
children=[
|
| 199 |
+
# Fortalezas
|
| 200 |
+
html.Div(className="aim-card", children=[
|
| 201 |
+
_columna_dominios(
|
| 202 |
+
"Fortalezas identificadas", "✦", "#34d399",
|
| 203 |
+
fortalezas, "strength",
|
| 204 |
+
),
|
| 205 |
+
]),
|
| 206 |
+
# Áreas de mejora
|
| 207 |
+
html.Div(className="aim-card", children=[
|
| 208 |
+
_columna_dominios(
|
| 209 |
+
"Áreas de mejora", "◈", "#f87171",
|
| 210 |
+
debilidades, "weakness",
|
| 211 |
+
),
|
| 212 |
+
]),
|
| 213 |
+
# Diagrama Venn con zoom modal
|
| 214 |
+
html.Div(className="aim-card", style={"textAlign": "center"}, children=[
|
| 215 |
+
html.Div("Cobertura AIM", className="aim-card-label"),
|
| 216 |
+
# Imagen clickeable
|
| 217 |
+
html.Div(
|
| 218 |
+
style={"position": "relative", "cursor": "zoom-in"},
|
| 219 |
+
children=[
|
| 220 |
+
html.Img(
|
| 221 |
+
src=venn_img,
|
| 222 |
+
className="venn-img venn-clickable",
|
| 223 |
+
id={"type": "venn-thumb", "index": i},
|
| 224 |
+
n_clicks=0,
|
| 225 |
+
),
|
| 226 |
+
html.Div(
|
| 227 |
+
"🔍 Clic para ampliar",
|
| 228 |
+
style={
|
| 229 |
+
"position": "absolute", "bottom": "10px",
|
| 230 |
+
"right": "10px", "background": "rgba(28,49,96,0.75)",
|
| 231 |
+
"color": "#fff", "fontSize": "0.75rem",
|
| 232 |
+
"padding": "4px 10px", "borderRadius": "999px",
|
| 233 |
+
"fontWeight": "600", "pointerEvents": "none",
|
| 234 |
+
}
|
| 235 |
+
),
|
| 236 |
+
],
|
| 237 |
+
),
|
| 238 |
+
html.P(
|
| 239 |
+
"✔ Dominios cubiertos ✗ Por desarrollar",
|
| 240 |
+
style={"color": "var(--text-muted)", "fontSize": "0.92rem",
|
| 241 |
+
"marginTop": "12px", "fontWeight": "500"},
|
| 242 |
+
),
|
| 243 |
+
# Modal overlay
|
| 244 |
+
html.Div(
|
| 245 |
+
id={"type": "venn-modal", "index": i},
|
| 246 |
+
style={"display": "none"},
|
| 247 |
+
className="venn-modal-overlay",
|
| 248 |
+
children=[
|
| 249 |
+
html.Div(className="venn-modal-box", children=[
|
| 250 |
+
html.Button(
|
| 251 |
+
"✕ Cerrar",
|
| 252 |
+
id={"type": "venn-close", "index": i},
|
| 253 |
+
className="venn-modal-close",
|
| 254 |
+
n_clicks=0,
|
| 255 |
+
),
|
| 256 |
+
html.Img(src=venn_img, className="venn-modal-img"),
|
| 257 |
+
]),
|
| 258 |
+
],
|
| 259 |
+
),
|
| 260 |
+
]),
|
| 261 |
+
],
|
| 262 |
+
),
|
| 263 |
+
],
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
layout_home = crear_layout_home()
|
| 268 |
+
all_layouts = [crear_layout_perfil(i) for i in range(1, 6)]
|
logic/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# logic package
|
logic/extractor.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Módulo de extracción y clasificación de texto desde sitios web.
|
| 3 |
+
Clase ExtractorMVD: navega el sitio, extrae contenido relevante y
|
| 4 |
+
clasifica oraciones en categorías MISIÓN, VISIÓN y DESCRIPCIÓN.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import re
|
| 8 |
+
import logging
|
| 9 |
+
|
| 10 |
+
import requests
|
| 11 |
+
from bs4 import BeautifulSoup
|
| 12 |
+
import trafilatura
|
| 13 |
+
import nltk
|
| 14 |
+
from urllib.parse import urljoin
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
# Descarga de recursos NLTK solo si no están disponibles
|
| 19 |
+
def _asegurar_nltk():
|
| 20 |
+
for recurso in ("punkt", "punkt_tab"):
|
| 21 |
+
try:
|
| 22 |
+
nltk.data.find(f"tokenizers/{recurso}")
|
| 23 |
+
except LookupError:
|
| 24 |
+
nltk.download(recurso, quiet=True)
|
| 25 |
+
|
| 26 |
+
_asegurar_nltk()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class ExtractorMVD:
|
| 30 |
+
"""
|
| 31 |
+
Extrae y clasifica el contenido de texto de un sitio web corporativo.
|
| 32 |
+
|
| 33 |
+
Uso:
|
| 34 |
+
extractor = ExtractorMVD(url="https://empresa.cl", nombre="Empresa S.A.")
|
| 35 |
+
extractor.navegar_y_extraer()
|
| 36 |
+
resultado = extractor.clasificar_inteligente()
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
# Rutas de fallback cuando no se encuentran links de navegación
|
| 40 |
+
RUTAS_FALLBACK = [
|
| 41 |
+
"/nosotros", "/pages/nosotros", "/quienes-somos", "/somos",
|
| 42 |
+
"/about", "/mision", "/conocenos", "/about-us",
|
| 43 |
+
"/empresa.htm", "/nosotros-2", "/nuestra-empresa",
|
| 44 |
+
]
|
| 45 |
+
|
| 46 |
+
def __init__(self, url: str, nombre: str):
|
| 47 |
+
self.url_base = url
|
| 48 |
+
self.nombre = nombre
|
| 49 |
+
self.paginas_candidatas: list[str] = []
|
| 50 |
+
self.datos_crudos: list[dict] = []
|
| 51 |
+
|
| 52 |
+
# Keywords para detectar páginas "Acerca de" en la navegación
|
| 53 |
+
self._nav_keywords = [
|
| 54 |
+
"desarrollo", "ubicada", "nosotros", "nosotras", "quienes",
|
| 55 |
+
"misión", "vision", "historia", "origen", "equipo", "about",
|
| 56 |
+
"propósito", "manifiesto", "impacto", "sobre",
|
| 57 |
+
"we", "us", "who", "mission", "vision", "history", "origin",
|
| 58 |
+
"team", "purpose", "manifesto", "impact",
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
# Patrones de clasificación semántica
|
| 62 |
+
self._patrones = {
|
| 63 |
+
"MISION": {
|
| 64 |
+
"fuerte": [
|
| 65 |
+
r"nos enfocamos", r"buscamos hacer", r"nuestra misión",
|
| 66 |
+
r"nuestro propósito", r"nuestro objetivo", r"la misión es",
|
| 67 |
+
r"razón de ser", r"por qué existimos", r"nos dedicamos a",
|
| 68 |
+
r"nuestro compromiso es", r"our mision", r"our purpose",
|
| 69 |
+
r"our objective", r"mision is", r"we exist",
|
| 70 |
+
r"we are dedicated to", r"our commitment is",
|
| 71 |
+
],
|
| 72 |
+
"debil": [
|
| 73 |
+
r"trabajamos para", r"buscamos", r"ayudar a", r"solucionar",
|
| 74 |
+
r"dar una alternativa", r"reemplazar", r"permitir", r"entregar",
|
| 75 |
+
r"proveer", r"facilitar", r"impulsar", r"fomentar", r"promover",
|
| 76 |
+
r"asegurar", r"garantizar", r"contribuir", r"aportar",
|
| 77 |
+
r"generar valor", r"we work for", r"we search", r"help",
|
| 78 |
+
r"solve", r"give an alternative", r"replace", r"allow",
|
| 79 |
+
r"deliver", r"provide", r"facilitate", r"encourage",
|
| 80 |
+
r"assure", r"guarantee", r"contribute", r"create value",
|
| 81 |
+
],
|
| 82 |
+
},
|
| 83 |
+
"VISION": {
|
| 84 |
+
"fuerte": [
|
| 85 |
+
r"nuestra visión", r"queremos ser", r"seremos", r"proyectamos",
|
| 86 |
+
r"hacia el futuro", r"nuestro sueño", r"soñamos con",
|
| 87 |
+
r"aspiramos a", r"horizonte", r"queremos llevar",
|
| 88 |
+
r"our vision", r"we want to be", r"we will be", r"we project",
|
| 89 |
+
r"into the future", r"our dream", r"we dream of",
|
| 90 |
+
r"we aspire to", r"horizon", r"we want to take",
|
| 91 |
+
],
|
| 92 |
+
"debil": [
|
| 93 |
+
r"convertirnos en", r"liderar", r"referente", r"mundo",
|
| 94 |
+
r"global", r"internacional", r"latinoamérica", r"impacto real",
|
| 95 |
+
r"cambio es necesario", r"otra forma de vida", r"revolucionar",
|
| 96 |
+
r"transformar", r"redefinir", r"innovación constante",
|
| 97 |
+
r"vanguardia", r"consolidarnos", r"reconocidos por",
|
| 98 |
+
r"largo plazo", r"transform", r"become", r"lead", r"world",
|
| 99 |
+
r"international", r"latin america", r"real impact",
|
| 100 |
+
r"necessary change", r"another way of life", r"revolutionize",
|
| 101 |
+
r"redefine", r"constant innovation", r"vanguard",
|
| 102 |
+
r"consolidate", r"recognized by", r"long term",
|
| 103 |
+
],
|
| 104 |
+
},
|
| 105 |
+
"DESCRIPCION": {
|
| 106 |
+
"fuerte": [
|
| 107 |
+
r"somos", r"fundada en", r"experiencia", r"historia comienza",
|
| 108 |
+
r"nació en", r"comenzó como", r"trayectoria", r"nuestros inicios",
|
| 109 |
+
r"quienes somos", r"equipo de",
|
| 110 |
+
r"as in our name", r"we are", r"founded in", r"experience",
|
| 111 |
+
r"history start", r"born in", r"started as", r"trajectory",
|
| 112 |
+
r"our begginings", r"who we are", r"team of",
|
| 113 |
+
],
|
| 114 |
+
"debil": [
|
| 115 |
+
r"empresa", r"compañía", r"consultora", r"organización",
|
| 116 |
+
r"startup", r"agencia", r"firma", r"ofrecemos", r"servicios",
|
| 117 |
+
r"productos", r"plataforma", r"soluciones", r"herramientas",
|
| 118 |
+
r"ubicados en", r"especialistas en", r"expertos en",
|
| 119 |
+
r"más de \d+ años", r"experiencia en", r"presencia en",
|
| 120 |
+
r"company", r"consultant", r"organization", r"agency",
|
| 121 |
+
r"sign", r"we offer", r"services", r"products", r"platform",
|
| 122 |
+
r"solutions", r"tools", r"located in", r"specialists in",
|
| 123 |
+
r"experts in", r"more than \d+ years", r"experience in",
|
| 124 |
+
r"presence in",
|
| 125 |
+
],
|
| 126 |
+
},
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
self._blacklisted_words = [
|
| 130 |
+
"leer más", "read more", "ver más", "cookies", "derechos reservados",
|
| 131 |
+
"copyright", "iniciar sesión", "carrito", "despachos", "envíos",
|
| 132 |
+
"vacaciones", "feriado", "horario de atención", "subscribe", "boletín",
|
| 133 |
+
"plastic free july", "sumate", "síguenos", "formulario", "censura",
|
| 134 |
+
"see more", "all rights reserved", "fifa", "concurso", "incumplimiento",
|
| 135 |
+
"teléfono", "link", "horario", "log in", "shopping cart", "shipping",
|
| 136 |
+
"deliveries", "holidays", "opening hours", "newsletter", "join us",
|
| 137 |
+
"follow us", "cookie",
|
| 138 |
+
]
|
| 139 |
+
|
| 140 |
+
# ------------------------------------------------------------------
|
| 141 |
+
# Navegación y extracción
|
| 142 |
+
# ------------------------------------------------------------------
|
| 143 |
+
|
| 144 |
+
def navegar_y_extraer(self) -> None:
|
| 145 |
+
"""Recorre el sitio web y almacena el texto extraído de cada página."""
|
| 146 |
+
try:
|
| 147 |
+
headers = {"User-Agent": "Mozilla/5.0"}
|
| 148 |
+
response = requests.get(self.url_base, headers=headers, timeout=20)
|
| 149 |
+
response.raise_for_status()
|
| 150 |
+
soup = BeautifulSoup(response.content, "html.parser")
|
| 151 |
+
self._encontrar_paginas_candidatas(soup)
|
| 152 |
+
except requests.RequestException as e:
|
| 153 |
+
logger.warning("Error al acceder a %s: %s", self.url_base, e)
|
| 154 |
+
|
| 155 |
+
# Siempre incluir la página raíz
|
| 156 |
+
if self.url_base not in self.paginas_candidatas:
|
| 157 |
+
self.paginas_candidatas.append(self.url_base)
|
| 158 |
+
|
| 159 |
+
self._extraer_textos()
|
| 160 |
+
|
| 161 |
+
def _encontrar_paginas_candidatas(self, soup: BeautifulSoup) -> None:
|
| 162 |
+
found_links: set[str] = set()
|
| 163 |
+
for link in soup.find_all("a", href=True):
|
| 164 |
+
href = link["href"]
|
| 165 |
+
texto_link = link.get_text().lower()
|
| 166 |
+
es_relevante = any(kw in texto_link for kw in self._nav_keywords) or \
|
| 167 |
+
any(kw in href.lower() for kw in self._nav_keywords)
|
| 168 |
+
if es_relevante:
|
| 169 |
+
full_url = urljoin(self.url_base, href)
|
| 170 |
+
if self.url_base in full_url and full_url not in found_links:
|
| 171 |
+
found_links.add(full_url)
|
| 172 |
+
self.paginas_candidatas.append(full_url)
|
| 173 |
+
|
| 174 |
+
if not self.paginas_candidatas:
|
| 175 |
+
for ruta in self.RUTAS_FALLBACK:
|
| 176 |
+
self.paginas_candidatas.append(urljoin(self.url_base, ruta))
|
| 177 |
+
|
| 178 |
+
def _extraer_textos(self) -> None:
|
| 179 |
+
for url in self.paginas_candidatas:
|
| 180 |
+
try:
|
| 181 |
+
downloaded = trafilatura.fetch_url(url)
|
| 182 |
+
if downloaded:
|
| 183 |
+
texto = trafilatura.extract(
|
| 184 |
+
downloaded,
|
| 185 |
+
include_comments=False,
|
| 186 |
+
include_tables=False,
|
| 187 |
+
include_links=False,
|
| 188 |
+
favor_precision=True,
|
| 189 |
+
)
|
| 190 |
+
if texto:
|
| 191 |
+
self.datos_crudos.append({"url": url, "texto": texto})
|
| 192 |
+
except Exception as e:
|
| 193 |
+
logger.debug("No se pudo extraer texto de %s: %s", url, e)
|
| 194 |
+
|
| 195 |
+
# ------------------------------------------------------------------
|
| 196 |
+
# Limpieza y validación
|
| 197 |
+
# ------------------------------------------------------------------
|
| 198 |
+
|
| 199 |
+
def _limpiar_texto(self, oracion: str) -> str:
|
| 200 |
+
oracion = re.sub(r"(?i)(leer\s+m[áa]s|read\s+more|ver\s+m[áa]s|ver\s+detalle)\.*", "", oracion)
|
| 201 |
+
oracion = re.sub(r"\.{2,}", "", oracion)
|
| 202 |
+
return oracion.strip()
|
| 203 |
+
|
| 204 |
+
def _validar_integridad(self, oracion: str) -> bool:
|
| 205 |
+
if not re.search(r'[.!?"]$', oracion):
|
| 206 |
+
return False
|
| 207 |
+
letras = [c for c in oracion if c.isalpha()]
|
| 208 |
+
if len(letras) > 10:
|
| 209 |
+
mayusculas = [c for c in letras if c.isupper()]
|
| 210 |
+
if len(mayusculas) / len(letras) > 0.6:
|
| 211 |
+
return False
|
| 212 |
+
return True
|
| 213 |
+
|
| 214 |
+
# ------------------------------------------------------------------
|
| 215 |
+
# Clasificación semántica
|
| 216 |
+
# ------------------------------------------------------------------
|
| 217 |
+
|
| 218 |
+
def clasificar_inteligente(self) -> dict | None:
|
| 219 |
+
"""
|
| 220 |
+
Clasifica el texto extraído en categorías MISIÓN, VISIÓN y DESCRIPCIÓN.
|
| 221 |
+
|
| 222 |
+
Returns:
|
| 223 |
+
Dict {nombre_empresa: {"MISION": [...], "VISION": [...], "DESCRIPCION": [...]}}
|
| 224 |
+
o None si no hay datos.
|
| 225 |
+
"""
|
| 226 |
+
if not self.datos_crudos:
|
| 227 |
+
logger.warning("No se encontraron datos para clasificar en %s", self.url_base)
|
| 228 |
+
return None
|
| 229 |
+
|
| 230 |
+
resultados: dict[str, list] = {"MISION": [], "VISION": [], "DESCRIPCION": []}
|
| 231 |
+
oraciones_procesadas: set[str] = set()
|
| 232 |
+
|
| 233 |
+
for fuente in self.datos_crudos:
|
| 234 |
+
texto_raw = fuente["texto"]
|
| 235 |
+
texto_limpio = re.sub(r"\s+", " ", texto_raw)
|
| 236 |
+
|
| 237 |
+
oraciones = nltk.sent_tokenize(texto_limpio)
|
| 238 |
+
|
| 239 |
+
for oracion in oraciones:
|
| 240 |
+
oracion = self._limpiar_texto(oracion)
|
| 241 |
+
if len(oracion) < 25 or oracion in oraciones_procesadas:
|
| 242 |
+
continue
|
| 243 |
+
|
| 244 |
+
oracion_lower = oracion.lower()
|
| 245 |
+
if any(bad in oracion_lower for bad in self._blacklisted_words):
|
| 246 |
+
continue
|
| 247 |
+
if not self._validar_integridad(oracion):
|
| 248 |
+
continue
|
| 249 |
+
|
| 250 |
+
mejor_cat = None
|
| 251 |
+
max_puntaje = 0
|
| 252 |
+
|
| 253 |
+
for categoria, tipos in self._patrones.items():
|
| 254 |
+
puntaje = sum(
|
| 255 |
+
10 for p in tipos["fuerte"] if re.search(p, oracion_lower)
|
| 256 |
+
) + sum(
|
| 257 |
+
3 for p in tipos["debil"] if re.search(p, oracion_lower)
|
| 258 |
+
)
|
| 259 |
+
if puntaje > max_puntaje:
|
| 260 |
+
max_puntaje = puntaje
|
| 261 |
+
mejor_cat = categoria
|
| 262 |
+
|
| 263 |
+
if mejor_cat and max_puntaje >= 3:
|
| 264 |
+
resultados[mejor_cat].append(oracion)
|
| 265 |
+
oraciones_procesadas.add(oracion)
|
| 266 |
+
|
| 267 |
+
return {self.nombre: resultados}
|
logic/modelo.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Módulo de clasificación mediante modelos de NLP y K-Means.
|
| 3 |
+
Contiene la lógica de vectorización semántica y predicción de perfil AIM.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import torch
|
| 12 |
+
import joblib
|
| 13 |
+
from sentence_transformers import SentenceTransformer, CrossEncoder, util
|
| 14 |
+
from transformers import pipeline
|
| 15 |
+
from deep_translator import GoogleTranslator
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
# Ruta del modelo K-Means (relativa al directorio de este archivo)
|
| 20 |
+
MODEL_PATH = Path(__file__).parent.parent / "Modelo_Pymes.pkl"
|
| 21 |
+
|
| 22 |
+
# Pesos del comité de expertos
|
| 23 |
+
W_BART = 0.50
|
| 24 |
+
W_MPNET = 0.30
|
| 25 |
+
W_NLI = 0.20
|
| 26 |
+
|
| 27 |
+
# Umbrales de corte
|
| 28 |
+
SCORE_MINIMO = 0.15
|
| 29 |
+
UMBRAL_CORTE = 0.08
|
| 30 |
+
DOMINIOS_NUCLEO = ["Risk", "Policy and Strategy", "Knowledge and Capabilities"]
|
| 31 |
+
|
| 32 |
+
# Herencia AIM: qué pilares influyen a cada dominio
|
| 33 |
+
HERENCIA_AIM = {
|
| 34 |
+
"Risk": ["AWARENESS", "INFRASTRUCTURE", "MANAGEMENT"],
|
| 35 |
+
"Policy and Strategy": ["AWARENESS", "INFRASTRUCTURE", "MANAGEMENT"],
|
| 36 |
+
"Knowledge and Capabilities": ["AWARENESS", "INFRASTRUCTURE", "MANAGEMENT"],
|
| 37 |
+
"Incident Detection and Response": ["AWARENESS", "MANAGEMENT"],
|
| 38 |
+
"Program": ["MANAGEMENT", "INFRASTRUCTURE"],
|
| 39 |
+
"Standards and Technology": ["AWARENESS", "INFRASTRUCTURE"],
|
| 40 |
+
"Culture and Society": ["AWARENESS"],
|
| 41 |
+
"Situational Awareness": ["AWARENESS"],
|
| 42 |
+
"Architecture": ["INFRASTRUCTURE"],
|
| 43 |
+
"Threat and Vulnerability": ["INFRASTRUCTURE"],
|
| 44 |
+
"Legal and regulatory Framework": ["MANAGEMENT"],
|
| 45 |
+
"Workforce": ["MANAGEMENT"],
|
| 46 |
+
"Asset, Change, and Configuration": ["MANAGEMENT"],
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
# ----------------------------------------------------------------------------------
|
| 50 |
+
# Definiciones de dominios y pilares (textos largos de referencia para embeddings)
|
| 51 |
+
# Extraídos del archivo original sin modificación de contenido.
|
| 52 |
+
# ----------------------------------------------------------------------------------
|
| 53 |
+
|
| 54 |
+
BASE_DOMINIOS_AMPLIADOS = {
|
| 55 |
+
"Culture and Society": """
|
| 56 |
+
### DOMAIN: CULTURE AND SOCIETY
|
| 57 |
+
This domain encapsulates the collective set of values, beliefs, perceptions, and behavioral norms
|
| 58 |
+
that determine how an institution and its stakeholders approach the protection of information assets.
|
| 59 |
+
It functions as the organization's informal operating system, governing the unwritten rules of conduct
|
| 60 |
+
that dictate whether official security directives are internalized as a shared responsibility or viewed
|
| 61 |
+
as bureaucratic impediments. Unlike technical controls that enforce limitations, this dimension focuses
|
| 62 |
+
on the willingness of human actors to adhere to safe practices even in the absence of direct supervision.
|
| 63 |
+
""",
|
| 64 |
+
"Situational Awareness": """
|
| 65 |
+
### DOMAIN: SITUATIONAL AWARENESS
|
| 66 |
+
This domain defines the organization's dynamic capacity to perceive, synthesize, and interpret the
|
| 67 |
+
status of its security environment in real-time. It bridges the semantic gap between technical anomalies
|
| 68 |
+
and business context, aggregating fragmented telemetry from disparate sources to construct a unified
|
| 69 |
+
Common Operating Picture. It answers: What is happening now? Who is the adversary? Which critical
|
| 70 |
+
functions are implicated?
|
| 71 |
+
""",
|
| 72 |
+
"Standards and Technology": """
|
| 73 |
+
### DOMAIN: STANDARDS AND TECHNOLOGY
|
| 74 |
+
This domain constitutes the technical realization of cybersecurity: the rigorous selection, implementation,
|
| 75 |
+
and maintenance of the hardware, software, and configuration frameworks that enforce protection.
|
| 76 |
+
Standards refer to externally validated frameworks (NIST CSF, ISO 27001, CIS Benchmarks).
|
| 77 |
+
Technology refers to the specific operational tools deployed to execute those standards.
|
| 78 |
+
""",
|
| 79 |
+
"Architecture": """
|
| 80 |
+
### DOMAIN: ARCHITECTURE
|
| 81 |
+
This domain defines the structural design, organization, and interconnection of an institution's digital
|
| 82 |
+
ecosystem. It translates abstract security principles such as defense-in-depth, least privilege, and
|
| 83 |
+
resilience into concrete, enforceable topologies. The fundamental objective is to limit the blast radius
|
| 84 |
+
of a potential compromise through network segmentation, Zero Trust models, and cloud landing zones.
|
| 85 |
+
""",
|
| 86 |
+
"Threat and Vulnerability": """
|
| 87 |
+
### DOMAIN: THREAT AND VULNERABILITY
|
| 88 |
+
This domain encapsulates the organization's dynamic capability to proactively identify, evaluate, and
|
| 89 |
+
mitigate security weaknesses before they can be exploited. It governs the operational lifecycle of a flaw:
|
| 90 |
+
from detection (scanning/reporting) to assessment (scoring based on exploitability and asset criticality)
|
| 91 |
+
and finally to remediation or compensating controls.
|
| 92 |
+
""",
|
| 93 |
+
"Program": """
|
| 94 |
+
### DOMAIN: PROGRAM
|
| 95 |
+
This domain refers to the strategic planning and execution of cybersecurity as a formal organizational
|
| 96 |
+
program. It ensures that security initiatives are funded, staffed, sequenced, and tracked as a coherent
|
| 97 |
+
portfolio of work aligned with business objectives and risk tolerance.
|
| 98 |
+
""",
|
| 99 |
+
"Workforce": """
|
| 100 |
+
### DOMAIN: WORKFORCE
|
| 101 |
+
This domain encompasses the people dimension of cybersecurity: recruiting, retaining, and developing
|
| 102 |
+
security talent; defining roles and responsibilities; and ensuring that all staff have the skills and
|
| 103 |
+
authority required to execute their security functions effectively.
|
| 104 |
+
""",
|
| 105 |
+
"Asset, Change and Configuration": """
|
| 106 |
+
### DOMAIN: ASSET, CHANGE AND CONFIGURATION
|
| 107 |
+
This domain refers to the governance and control of the organization's digital and physical assets,
|
| 108 |
+
including inventory management, configuration baselines, and change control processes that prevent
|
| 109 |
+
unauthorized or insecure modifications to the technology estate.
|
| 110 |
+
""",
|
| 111 |
+
"Legal and Regulatory Framework": """
|
| 112 |
+
### DOMAIN: LEGAL AND REGULATORY FRAMEWORK
|
| 113 |
+
This domain refers to the laws, regulations, contractual obligations, and industry standards that govern
|
| 114 |
+
the organization's security posture. It ensures that the organization meets its compliance obligations
|
| 115 |
+
while translating external mandates into internal controls and policies.
|
| 116 |
+
""",
|
| 117 |
+
"Incident Detection and Response": """
|
| 118 |
+
### DOMAIN: INCIDENT DETECTION AND RESPONSE
|
| 119 |
+
This domain refers to the organization's capability to detect, analyze, contain, eradicate, and recover
|
| 120 |
+
from security incidents in a timely and effective manner. It encompasses the people, processes, and
|
| 121 |
+
technology that form the incident lifecycle, from initial alert triage to post-incident review.
|
| 122 |
+
""",
|
| 123 |
+
"Policy and Strategy": """
|
| 124 |
+
### DOMAIN: POLICY AND STRATEGY
|
| 125 |
+
This domain refers to the capacity of an organization to establish formal policies, standards, and a
|
| 126 |
+
coherent security strategy that aligns protection investments with business objectives and risk appetite.
|
| 127 |
+
It provides the governing framework within which all other security activities operate.
|
| 128 |
+
""",
|
| 129 |
+
"Knowledge and Capabilities": """
|
| 130 |
+
### DOMAIN: KNOWLEDGE AND CAPABILITIES
|
| 131 |
+
This domain refers to the organization's institutional knowledge base and the specialized competencies
|
| 132 |
+
required to execute its security strategy. It encompasses threat intelligence, security research, and
|
| 133 |
+
the continuous development of skills that keep the organization ahead of the evolving threat landscape.
|
| 134 |
+
""",
|
| 135 |
+
"Risk": """
|
| 136 |
+
### DOMAIN: RISK
|
| 137 |
+
This domain refers to the systematic process of identifying, assessing, prioritizing, and managing
|
| 138 |
+
threats to the organization's information assets. It provides the analytical framework for converting
|
| 139 |
+
technical vulnerabilities and threat intelligence into business impact language, enabling
|
| 140 |
+
defensible resource allocation decisions.
|
| 141 |
+
""",
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
BASE_PILARES = {
|
| 145 |
+
"AWARENESS": """
|
| 146 |
+
### PILLAR: AWARENESS
|
| 147 |
+
Awareness constitutes the cognitive and behavioral layer of the organization's cybersecurity posture.
|
| 148 |
+
It represents the internalization of risk management into the daily heuristics of the workforce,
|
| 149 |
+
transforming the human element from a potential vulnerability into a sophisticated sensor network.
|
| 150 |
+
It includes security champions, phishing simulations, role-based training, and reporting mechanisms.
|
| 151 |
+
""",
|
| 152 |
+
"INFRASTRUCTURE": """
|
| 153 |
+
### PILLAR: INFRASTRUCTURE
|
| 154 |
+
Infrastructure represents the tangible, operative reality of cybersecurity: the collection of hardware,
|
| 155 |
+
software, networks, and architectural mechanisms that materially enforce protection. It encompasses
|
| 156 |
+
network segmentation, endpoint detection, hardening baselines, encryption, and resilience testing.
|
| 157 |
+
It ensures that Defense in Depth is an operational fact rather than a theoretical concept.
|
| 158 |
+
""",
|
| 159 |
+
"MANAGEMENT": """
|
| 160 |
+
### PILLAR: MANAGEMENT
|
| 161 |
+
Management constitutes the executive and strategic brain of the cybersecurity ecosystem. It encompasses
|
| 162 |
+
governance structures, risk registers, security budgets, policy frameworks, and executive accountability
|
| 163 |
+
mechanisms that ensure security is managed as a critical business function aligned with fiduciary duties.
|
| 164 |
+
""",
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# ----------------------------------------------------------------------------------
|
| 169 |
+
# Funciones de traducción y vectorización
|
| 170 |
+
# ----------------------------------------------------------------------------------
|
| 171 |
+
|
| 172 |
+
def _traducir_texto_largo(texto: dict) -> str:
|
| 173 |
+
"""Traduce el diccionario de texto clasificado al inglés, en chunks si es necesario."""
|
| 174 |
+
translator = GoogleTranslator(source="es", target="en")
|
| 175 |
+
limite = 4_000
|
| 176 |
+
partes_traducidas = []
|
| 177 |
+
|
| 178 |
+
for llave, texto_original in texto.items():
|
| 179 |
+
texto_original = str(texto_original)
|
| 180 |
+
if len(texto_original) <= limite:
|
| 181 |
+
try:
|
| 182 |
+
partes_traducidas.append(translator.translate(texto_original))
|
| 183 |
+
except Exception:
|
| 184 |
+
partes_traducidas.append(texto_original)
|
| 185 |
+
else:
|
| 186 |
+
for i in range(0, len(texto_original), limite):
|
| 187 |
+
chunk = texto_original[i : i + limite]
|
| 188 |
+
try:
|
| 189 |
+
partes_traducidas.append(translator.translate(chunk))
|
| 190 |
+
except Exception:
|
| 191 |
+
partes_traducidas.append(chunk)
|
| 192 |
+
|
| 193 |
+
return " ".join(partes_traducidas).strip()
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def _calcular_similitud(
|
| 197 |
+
texto: str,
|
| 198 |
+
nombres: list,
|
| 199 |
+
definiciones: list,
|
| 200 |
+
embeddings_ref,
|
| 201 |
+
model_A,
|
| 202 |
+
model_B,
|
| 203 |
+
model_C,
|
| 204 |
+
) -> dict:
|
| 205 |
+
"""Calcula similitud fusionada usando MPNet + DeBERTa + BART."""
|
| 206 |
+
|
| 207 |
+
# MPNet (semántico)
|
| 208 |
+
emb_texto = model_A.encode(texto, convert_to_tensor=True)
|
| 209 |
+
scores_A = util.cos_sim(emb_texto, embeddings_ref)[0].cpu().numpy()
|
| 210 |
+
if scores_A.max() > scores_A.min():
|
| 211 |
+
scores_A = (scores_A - scores_A.min()) / (scores_A.max() - scores_A.min())
|
| 212 |
+
|
| 213 |
+
# DeBERTa (lógico / NLI)
|
| 214 |
+
pares = [[texto, d] for d in definiciones]
|
| 215 |
+
scores_B_logits = model_B.predict(pares)
|
| 216 |
+
scores_B = np.max(scores_B_logits, axis=1) if len(scores_B_logits.shape) > 1 else scores_B_logits
|
| 217 |
+
if scores_B.max() > scores_B.min():
|
| 218 |
+
scores_B = (scores_B - scores_B.min()) / (scores_B.max() - scores_B.min())
|
| 219 |
+
|
| 220 |
+
# BART (zero-shot contextual)
|
| 221 |
+
res_C = model_C(texto, nombres, multi_label=True)
|
| 222 |
+
mapa_C = dict(zip(res_C["labels"], res_C["scores"]))
|
| 223 |
+
scores_C = np.array([mapa_C[n] for n in nombres])
|
| 224 |
+
|
| 225 |
+
finales = (scores_C * W_BART) + (scores_A * W_MPNET) + (scores_B * W_NLI)
|
| 226 |
+
|
| 227 |
+
return {
|
| 228 |
+
nombre: {
|
| 229 |
+
"final": finales[i],
|
| 230 |
+
"bart": scores_C[i],
|
| 231 |
+
"mpnet": scores_A[i],
|
| 232 |
+
"nli": scores_B[i],
|
| 233 |
+
}
|
| 234 |
+
for i, nombre in enumerate(nombres)
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def _vectorizar(texto: dict) -> pd.DataFrame:
|
| 239 |
+
"""
|
| 240 |
+
Vectoriza el texto clasificado y devuelve un DataFrame con scores por dominio.
|
| 241 |
+
Carga los modelos NLP bajo demanda (solo cuando se llama).
|
| 242 |
+
"""
|
| 243 |
+
device = 0 if torch.cuda.is_available() else -1
|
| 244 |
+
|
| 245 |
+
logger.info("Cargando modelos NLP...")
|
| 246 |
+
model_A = SentenceTransformer("all-mpnet-base-v2")
|
| 247 |
+
model_B = CrossEncoder("cross-encoder/nli-deberta-v3-base")
|
| 248 |
+
model_C = pipeline("zero-shot-classification", model="facebook/bart-large-mnli", device=device)
|
| 249 |
+
|
| 250 |
+
nombres_dominios = list(BASE_DOMINIOS_AMPLIADOS.keys())
|
| 251 |
+
defs_dominios = list(BASE_DOMINIOS_AMPLIADOS.values())
|
| 252 |
+
emb_dominios = model_A.encode(defs_dominios, convert_to_tensor=True)
|
| 253 |
+
|
| 254 |
+
nombres_pilares = list(BASE_PILARES.keys())
|
| 255 |
+
defs_pilares = list(BASE_PILARES.values())
|
| 256 |
+
emb_pilares = model_A.encode(defs_pilares, convert_to_tensor=True)
|
| 257 |
+
|
| 258 |
+
texto_clean = _traducir_texto_largo(texto)
|
| 259 |
+
|
| 260 |
+
scores_dominios = _calcular_similitud(
|
| 261 |
+
texto_clean, nombres_dominios, defs_dominios, emb_dominios,
|
| 262 |
+
model_A, model_B, model_C,
|
| 263 |
+
)
|
| 264 |
+
scores_pilares = _calcular_similitud(
|
| 265 |
+
texto_clean, nombres_pilares, defs_pilares, emb_pilares,
|
| 266 |
+
model_A, model_B, model_C,
|
| 267 |
+
)
|
| 268 |
+
scores_pilares_simple = {k: v["final"] for k, v in scores_pilares.items()}
|
| 269 |
+
|
| 270 |
+
P_BASE = 0.60
|
| 271 |
+
P_HERENCIA = 0.40
|
| 272 |
+
datos_tabla = []
|
| 273 |
+
|
| 274 |
+
for dominio, detalle in scores_dominios.items():
|
| 275 |
+
padres = HERENCIA_AIM.get(dominio, [])
|
| 276 |
+
score_herencia = (
|
| 277 |
+
sum(scores_pilares_simple[p] for p in padres) / len(padres)
|
| 278 |
+
if padres else 0.0
|
| 279 |
+
)
|
| 280 |
+
bono = 0.10 if len(padres) == 3 else 0.0
|
| 281 |
+
score_final = (detalle["final"] * P_BASE) + (score_herencia * P_HERENCIA) + bono
|
| 282 |
+
|
| 283 |
+
datos_tabla.append({
|
| 284 |
+
"Categoría": dominio,
|
| 285 |
+
"Final": score_final,
|
| 286 |
+
"BART": detalle["bart"],
|
| 287 |
+
"MPNet": detalle["mpnet"],
|
| 288 |
+
"NLI": detalle["nli"],
|
| 289 |
+
"Base": detalle["final"],
|
| 290 |
+
"Herencia": score_herencia,
|
| 291 |
+
})
|
| 292 |
+
|
| 293 |
+
df = (
|
| 294 |
+
pd.DataFrame(datos_tabla)
|
| 295 |
+
.sort_values(by="Final", ascending=False)
|
| 296 |
+
.reset_index(drop=True)
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
# Calcular saltos para el criterio de corte
|
| 300 |
+
df["Salto"] = df["Final"].diff(periods=-1).fillna(0)
|
| 301 |
+
|
| 302 |
+
indice_corte = len(df)
|
| 303 |
+
for idx, row in df.iterrows():
|
| 304 |
+
siguiente_dominio = df.iloc[idx + 1]["Categoría"] if idx + 1 < len(df) else ""
|
| 305 |
+
score_siguiente = df.iloc[idx + 1]["Final"] if idx + 1 < len(df) else 0
|
| 306 |
+
|
| 307 |
+
if row["Final"] < SCORE_MINIMO:
|
| 308 |
+
indice_corte = idx
|
| 309 |
+
break
|
| 310 |
+
|
| 311 |
+
if row["Salto"] > UMBRAL_CORTE:
|
| 312 |
+
nucleo_rescatable = (
|
| 313 |
+
siguiente_dominio in DOMINIOS_NUCLEO and score_siguiente >= SCORE_MINIMO
|
| 314 |
+
)
|
| 315 |
+
if not nucleo_rescatable:
|
| 316 |
+
indice_corte = idx + 1
|
| 317 |
+
break
|
| 318 |
+
|
| 319 |
+
return df * 100 # Convertir a porcentajes
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def obtener_perfil(texto: dict) -> int:
|
| 323 |
+
"""
|
| 324 |
+
Clasifica el texto extraído y retorna el índice del perfil (0-4).
|
| 325 |
+
|
| 326 |
+
Args:
|
| 327 |
+
texto: Diccionario {nombre_empresa: {MISION: [...], VISION: [...], DESCRIPCION: [...]}}.
|
| 328 |
+
|
| 329 |
+
Returns:
|
| 330 |
+
Entero entre 0 y 4 (índice de cluster K-Means).
|
| 331 |
+
|
| 332 |
+
Raises:
|
| 333 |
+
FileNotFoundError: Si no se encuentra el archivo del modelo.
|
| 334 |
+
ValueError: Si el texto es None o vacío.
|
| 335 |
+
"""
|
| 336 |
+
if not texto:
|
| 337 |
+
raise ValueError("El texto de entrada está vacío o es None.")
|
| 338 |
+
|
| 339 |
+
if not MODEL_PATH.exists():
|
| 340 |
+
raise FileNotFoundError(
|
| 341 |
+
f"No se encontró el modelo en: {MODEL_PATH}\n"
|
| 342 |
+
"Asegúrate de que 'Modelo_Pymes.pkl' esté en la carpeta raíz del proyecto."
|
| 343 |
+
)
|
| 344 |
+
|
| 345 |
+
vector = _vectorizar(texto)
|
| 346 |
+
kmeans = joblib.load(MODEL_PATH)
|
| 347 |
+
perfil = kmeans.predict(vector.iloc[:, 1].values.reshape(1, -1))
|
| 348 |
+
return int(perfil[0])
|
logic/venn.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Módulo de generación del diagrama de Venn para la Tríada AIM.
|
| 3 |
+
Genera imágenes base64 a partir de matplotlib + matplotlib_venn.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import io
|
| 7 |
+
import base64
|
| 8 |
+
import matplotlib
|
| 9 |
+
matplotlib.use("Agg")
|
| 10 |
+
import matplotlib.pyplot as plt
|
| 11 |
+
from matplotlib_venn import venn3
|
| 12 |
+
|
| 13 |
+
from data.definitions import (
|
| 14 |
+
COLORES_BASE,
|
| 15 |
+
VENN_SECTIONS,
|
| 16 |
+
VENN_ID_TO_SUBCATEGORIA,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
VENN_IDS = ["100", "010", "001", "110", "101", "011", "111"]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def generar_venn_base(foco=None, seleccionados_usuario=None) -> str:
|
| 23 |
+
"""
|
| 24 |
+
Genera el diagrama de Venn y retorna la imagen codificada en base64.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
foco: Modo de visualización. Opciones:
|
| 28 |
+
"Reporte" → muestra fortalezas/debilidades por perfil.
|
| 29 |
+
"Completo" → muestra todos los dominios con color.
|
| 30 |
+
"Simple" → solo color, sin texto.
|
| 31 |
+
str → destaca únicamente la zona con ese nombre (Core, Bridge, ...).
|
| 32 |
+
seleccionados_usuario: Lista de dominios activos (solo relevante en modo "Reporte").
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
String base64 PNG de la imagen generada.
|
| 36 |
+
"""
|
| 37 |
+
if seleccionados_usuario is None:
|
| 38 |
+
seleccionados_usuario = []
|
| 39 |
+
|
| 40 |
+
plt.figure(figsize=(11, 11))
|
| 41 |
+
venn = venn3(
|
| 42 |
+
subsets=(3, 2, 3, 1, 1, 1, 3),
|
| 43 |
+
set_labels=("Concientizacion", "Infraestructura", "Gestion"),
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
# Limpiar etiquetas por defecto
|
| 47 |
+
for vid in VENN_IDS:
|
| 48 |
+
label = venn.get_label_by_id(vid)
|
| 49 |
+
if label:
|
| 50 |
+
label.set_text("")
|
| 51 |
+
|
| 52 |
+
for vid in VENN_IDS:
|
| 53 |
+
patch = venn.get_patch_by_id(vid)
|
| 54 |
+
if not patch:
|
| 55 |
+
continue
|
| 56 |
+
|
| 57 |
+
section_name = VENN_SECTIONS[vid]
|
| 58 |
+
dominios_zona = VENN_ID_TO_SUBCATEGORIA[vid]
|
| 59 |
+
|
| 60 |
+
if foco == "Reporte":
|
| 61 |
+
hay_seleccion = any(d in seleccionados_usuario for d in dominios_zona)
|
| 62 |
+
color = COLORES_BASE[section_name] if hay_seleccion else COLORES_BASE["Desactivado"]
|
| 63 |
+
alpha = 0.8 if hay_seleccion else 0.3
|
| 64 |
+
|
| 65 |
+
texto_etiqueta = [
|
| 66 |
+
f"✔ {d}" if d in seleccionados_usuario else f"✗ {d}"
|
| 67 |
+
for d in dominios_zona
|
| 68 |
+
]
|
| 69 |
+
label = venn.get_label_by_id(vid)
|
| 70 |
+
if label:
|
| 71 |
+
label.set_text("\n".join(texto_etiqueta))
|
| 72 |
+
label.set_fontsize(8)
|
| 73 |
+
|
| 74 |
+
elif foco == "Completo":
|
| 75 |
+
color = COLORES_BASE[section_name]
|
| 76 |
+
alpha = 0.8
|
| 77 |
+
label = venn.get_label_by_id(vid)
|
| 78 |
+
if label:
|
| 79 |
+
label.set_text("\n".join(dominios_zona))
|
| 80 |
+
|
| 81 |
+
elif foco == "Simple":
|
| 82 |
+
color = COLORES_BASE[section_name]
|
| 83 |
+
alpha = 0.8
|
| 84 |
+
|
| 85 |
+
else:
|
| 86 |
+
# Foco específico: destaca solo la zona indicada
|
| 87 |
+
es_foco = section_name == foco
|
| 88 |
+
color = COLORES_BASE[section_name] if es_foco else COLORES_BASE["Desactivado"]
|
| 89 |
+
alpha = 0.9 if es_foco else 0.3
|
| 90 |
+
if es_foco:
|
| 91 |
+
label = venn.get_label_by_id(vid)
|
| 92 |
+
if label:
|
| 93 |
+
label.set_text("\n".join(dominios_zona))
|
| 94 |
+
|
| 95 |
+
patch.set_facecolor(color)
|
| 96 |
+
patch.set_edgecolor("black")
|
| 97 |
+
patch.set_linewidth(1.5)
|
| 98 |
+
patch.set_alpha(alpha)
|
| 99 |
+
|
| 100 |
+
if foco == "Reporte":
|
| 101 |
+
titulo = "Tríada AIM — Perfil de cobertura"
|
| 102 |
+
elif foco in ("Completo", "Simple"):
|
| 103 |
+
titulo = "Tríada AIM"
|
| 104 |
+
else:
|
| 105 |
+
titulo = f"Zona: {foco.upper()}" if foco else "Tríada AIM"
|
| 106 |
+
|
| 107 |
+
plt.title(titulo, fontsize=13, fontweight="bold")
|
| 108 |
+
|
| 109 |
+
buf = io.BytesIO()
|
| 110 |
+
plt.savefig(buf, format="png", bbox_inches="tight", dpi=150)
|
| 111 |
+
plt.close()
|
| 112 |
+
buf.seek(0)
|
| 113 |
+
return base64.b64encode(buf.getvalue()).decode("utf-8")
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# Pre-renderizado del Venn completo para la pantalla de inicio (se genera una sola vez)
|
| 117 |
+
VENN_IMG_COMPLETO = f"data:image/png;base64,{generar_venn_base('Completo')}"
|
render.yaml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
- type: web
|
| 3 |
+
name: aim-dashboard
|
| 4 |
+
runtime: python
|
| 5 |
+
buildCommand: pip install -r requirements.txt
|
| 6 |
+
startCommand: gunicorn app:server
|
| 7 |
+
envVars:
|
| 8 |
+
- key: PYTHON_VERSION
|
| 9 |
+
value: 3.13.2
|
requirements.txt
ADDED
|
File without changes
|