# Diseño — Portal taller-wox.fitlabs.dev

**Fecha:** 2026-05-12
**Deadline workshop:** 2026-05-14 (jueves, 48h)
**Owner:** Felipe Arentsen (FactorIT)
**Dominio:** `https://taller-wox.fitlabs.dev`
**Hosting:** VPS propio gestionado por Coolify
**Estado:** validado en brainstorming, listo para `writing-plans`

> Este documento consolida las decisiones tomadas durante la sesión de brainstorming del 2026-05-12 sobre el `SPEC_taller_wox_fitlabs.md` original. El SPEC sigue siendo la fuente de detalle para schemas, paletas, código de referencia y datasets de los endpoints. Este doc lo **modifica** donde corresponda y deja **explícito** qué queda fuera de alcance.

---

## 1. Contexto y restricciones

### 1.1 Qué es esto
Portal web + API para el "Bootcamp Agentic AI con watsonx Orchestrate" de FactorIT. Dos funciones simultáneas:

1. **Frontend público + registro de leads + descarga de material** del bootcamp.
2. **5 endpoints REST** consumidos en vivo por los agentes de watsonx Orchestrate que los alumnos construyen durante el taller.

### 1.2 Restricciones que mandan el diseño
- **48 horas hasta el primer uso real** (taller del jueves 2026-05-14).
- **Uso recurrente posterior** con cohortes futuras → el diseño debe **poder evolucionar**, no necesita **ya soportar** la evolución.
- **Cohorte pequeña** (~10-30 alumnos) → no necesita escalar.
- **Hosting Coolify** → todo va en un container Docker, HTTPS automático con Let's Encrypt, volúmenes persistentes declarados en la UI.
- **Watsonx Orchestrate rechaza HTTPS con certs inválidos** → Let's Encrypt automático resuelve esto.

### 1.3 Camino crítico para el jueves
1. Los 5 endpoints `/api/*` funcionando y reachables desde watsonx (HTTPS válido).
2. La página de descargas con los 2 ZIPs (uno técnico, uno funcional) accesibles.
3. El formulario de registro funcionando (lead capture).

Lo demás (admin, polish visual extra, etc.) es nice-to-have.

---

## 2. Arquitectura

### 2.1 Decisión: un solo servicio FastAPI
Una sola app FastAPI corriendo en un container Docker. Sirve 3 superficies en el mismo puerto:

- **Frontend + registro**: `GET /`, `GET /descargas`, `POST /register`, `GET /download/{file}`
- **API del taller**: los 5 endpoints `/api/*`
- **Admin básico**: `GET /admin/leads.json`, `GET /admin/leads.csv`, `GET /admin/stats` (con HTTP Basic auth)

**Por qué un solo servicio (y no split):** para 48h y una cohorte de ~30 alumnos, los costos operacionales de partir esto en dos servicios (otro container, otra config en Coolify, comunicación interna) superan ampliamente los beneficios. Aceptamos el riesgo de que un bug en el landing pueda afectar la API en vivo; lo mitigamos con un Dockerfile simple, un test smoke pre-deploy, y la decisión explícita de **no tocar nada el día del taller**.

**Cuándo reconsiderar:** si en cohortes futuras la API empieza a tener cambios frecuentes (nuevos endpoints por workshop, datasets dinámicos), separar API y frontend en dos servicios independientes pasa a ser justificable.

### 2.2 Stack
- **Backend:** Python 3.11+ con FastAPI + Uvicorn (single worker — suficiente para esta escala)
- **Templates:** Jinja2
- **Datos tabulares (endpoints 1 y 2):** pandas, CSVs cargados en memoria al boot del servicio
- **Charts (endpoint 5):** Plotly
- **Persistencia:** SQLite file-based (`leads.db`)
- **Tokens de descarga:** `itsdangerous.URLSafeTimedSerializer`, expiración 24h
- **Frontend:** HTML + CSS + JS vanilla, sin framework

Sin Redis, sin Postgres, sin Celery, sin frontend SPA.

### 2.3 Diagrama lógico

```
                       Internet
                          │
                          ▼
            ┌─────────────────────────────┐
            │  Coolify (VPS, Let's Encrypt)│
            └─────────────┬───────────────┘
                          │
                          ▼
            ┌─────────────────────────────┐
            │   Container Docker           │
            │  ┌────────────────────────┐  │
            │  │   FastAPI + Uvicorn    │  │
            │  │  ┌──────────────────┐  │  │
            │  │  │ / + /descargas + │  │  │
            │  │  │ /register +      │  │  │
            │  │  │ /download/{file} │  │  │
            │  │  ├──────────────────┤  │  │
            │  │  │  /api/* (los 5)  │  │  │
            │  │  ├──────────────────┤  │  │
            │  │  │  /admin/*        │  │  │
            │  │  └──────────────────┘  │  │
            │  └────────────────────────┘  │
            │                              │
            │  Volúmenes persistentes:     │
            │   • /app/leads.db            │
            │   • /app/app/data/           │
            │       reports_output/        │
            │   • /app/material/           │
            └──────────────────────────────┘
```

---

## 3. Componentes

### 3.1 Frontend público (landing)

Estructura visual del SPEC §3.2 sin cambios:
- Hero navy con CTA naranja "Acceder al material"
- 3 cards "¿Qué vas a construir?"
- 4 stats "El taller en números" (4h, 6 módulos, 0 líneas de código, 100% hands-on)
- Sección 4: formulario de registro inline
- Footer navy con logos y créditos

Branding: paleta del SPEC §3.1, tipografía Inter desde Google Fonts. Logo `LogoFIT.png` (a comprimir, queda en `static/img/`). Logo IBM watsonx Orchestrate: **placeholder de texto estilizado** ("powered by IBM watsonx Orchestrate") en el header derecho — se reemplaza por logo oficial cuando esté disponible, sin redeploy si lo dejamos como SVG inline editable.

### 3.2 Formulario de registro (`POST /register`)

**Campos:**
| Campo | Validación |
|---|---|
| `nombre` | text, min 2, max 80, required |
| `email` | email válido, required |
| `empresa` | text, min 2, max 100, required |
| `consentimiento` | checkbox, debe estar marcado |
| `website` | **honeypot** — campo invisible vía CSS `display:none`; si llega lleno, descartar silenciosamente con `200 OK` falso |

**Comportamiento con email duplicado:** si el email ya existe en la tabla `leads`, no es error — se actualiza `last_seen` (nueva columna), se incrementa `times_registered`, y se emite un token nuevo igual. Fricción cero. El email es lead capture, no auth.

**Respuesta exitosa:** redirección a `/descargas?token=<token>`.

**Token:** `itsdangerous.URLSafeTimedSerializer(SECRET_KEY).dumps({"email": email, "nombre": nombre})`, expira 24h (`TOKEN_EXPIRY_HOURS` env).

### 3.3 Página de descargas (`GET /descargas?token=...`)

Valida token. Si inválido o expirado → redirige a `/?error=token-invalido` y la home muestra banner "El link expiró, regístrate de nuevo".

Si válido, renderiza:
- Saludo "¡Hola {nombre}!"
- **2 cards grandes** (cambio importante vs SPEC §3.4 que tenía 8-9 cards):

  1. **🧩 Material técnico** → `taller-wox-tecnico.zip`
     Descripción: "Specs OpenAPI, configs y artefactos para importar a watsonx Orchestrate"
  2. **📚 Material funcional** → `taller-wox-funcional.zip`
     Descripción: "Manual paso a paso del bootcamp y deck de slides"

  Cada card: icono, título, descripción 1 línea, tamaño en MB (calculado dinámicamente al servir la página), botón naranja "Descargar".

**Cards hardcodeadas en el template** (template Jinja2 + lista Python en `app/frontend.py`). Si en el futuro se quiere editar copy sin redeploy, pasar a `app/data/downloads.json`.

**Contenido de los ZIPs:** **Felipe los arma y los sube** vía SFTP / file manager de Coolify directo al volumen `/app/material/`. El backend no decide, no genera, no transforma — solo sirve lo que esté en esa carpeta.

### 3.4 Descarga (`GET /download/{filename}?token=...`)

- Valida token query param. Si inválido → 401.
- Verifica que `filename` esté en una **whitelist hardcodeada** (`{"taller-wox-tecnico.zip", "taller-wox-funcional.zip"}`) para prevenir path traversal.
- Sirve el archivo desde `/app/material/{filename}` con `Content-Disposition: attachment`.
- Registra la descarga en la tabla `downloads` (lead_email, filename, ip, downloaded_at) — útil para `/admin/stats`.

### 3.5 API del taller (los 5 endpoints)

Todos los endpoints del SPEC §5 se mantienen **exactamente** como están especificados (mismo contrato, mismo schema, mismas respuestas). Esta sección solo enumera el alcance y enlaza al SPEC para el detalle.

| # | Endpoint | Tipo | Detalle |
|---|---|---|---|
| 1 | `POST /api/historical-procedures` | Filtros + group_by sobre CSV histórico | SPEC §5.1 |
| 2 | `POST /api/available-procedures` | Filtros + group_by sobre CSV catálogo | SPEC §5.2 |
| 3 | `GET /api/member-insights` | JSON estático del afiliado demo | SPEC §5.3 |
| 4 | `GET /api/schedule` | Guía estática de scheduling | SPEC §5.4 |
| 5 | `POST /api/reports/generate-report` | Genera HTML con Jinja2 + Plotly, guarda en disco, devuelve URL pública | SPEC §5.5 |

**CORS:** `allow_origins=["*"]`, `allow_methods=["GET", "POST"]` — necesario para que los agentes de watsonx Orchestrate puedan llamar los endpoints desde `*.cloud.ibm.com`.

**Sin** rate limiting, **sin** cleanup automático de los HTMLs generados (volumen esperado: ~150 archivos × 30 KB = 5 MB por taller, despreciable; si crece, se limpia con un cron manual o `rm` puntual).

**Datasets sintéticos** (los genero yo siguiendo el SPEC §9): ver sección 6 de este doc.

### 3.6 Admin básico

3 endpoints, todos detrás de HTTP Basic auth (`ADMIN_USER` + `ADMIN_PASS` desde env):

- `GET /admin/leads.json` — lista paginada de leads (`?limit=100&offset=0`)
- `GET /admin/leads.csv` — exporta todo como CSV descargable (Excel-friendly, UTF-8 con BOM para que Excel respete acentos)
- `GET /admin/stats` — JSON con: `total_leads`, `total_downloads`, `downloads_por_archivo`, `top_5_empresas`

Sin UI, sin gráficos, sin filtros. JSON crudo + CSV download.

---

## 4. Datos y persistencia

### 4.1 Esquema SQLite

```sql
CREATE TABLE IF NOT EXISTS leads (
    id                INTEGER PRIMARY KEY AUTOINCREMENT,
    nombre            TEXT NOT NULL,
    email             TEXT NOT NULL UNIQUE,
    empresa           TEXT NOT NULL,
    ip                TEXT,
    user_agent        TEXT,
    consent           INTEGER NOT NULL DEFAULT 0,
    times_registered  INTEGER NOT NULL DEFAULT 1,
    created_at        TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    last_seen         TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS downloads (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    lead_email    TEXT NOT NULL,
    filename      TEXT NOT NULL,
    ip            TEXT,
    downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_downloads_email ON downloads(lead_email);
CREATE INDEX IF NOT EXISTS idx_downloads_filename ON downloads(filename);
```

**Cambio vs SPEC §4.1:** agregué `UNIQUE` en `email`, `times_registered`, `last_seen` para soportar la lógica de duplicados decidida en brainstorming.

### 4.2 Lo que va en disco persistente

| Path en container | Qué contiene | Volumen Coolify |
|---|---|---|
| `/app/leads.db` | SQLite con `leads` y `downloads` | sí |
| `/app/app/data/reports_output/` | HTMLs generados por endpoint 5 | sí |
| `/app/material/` | Los 2 ZIPs que sube Felipe | sí |

### 4.3 Lo que va dentro del container (no persistente, viaja con la imagen)

- Código de la app (`/app/app/`)
- Datasets sintéticos de los endpoints (`/app/app/data/*.csv`, `*.json`, `*.txt`) — son inmutables, cambios pasan por commit + redeploy
- Static files (`/app/static/`) — CSS, JS, imágenes
- Templates Jinja2 (`/app/app/templates/`)

---

## 5. Deploy

### 5.1 Dockerfile

```dockerfile
FROM python:3.11-slim
WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app/ ./app/
COPY static/ ./static/

# crear directorios runtime (se montan como volúmenes en prod)
RUN mkdir -p app/data/reports_output material

EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
```

Sin healthcheck por simplicidad (Coolify pinguea HTTP `/` por defecto). Sin multi-stage build — la imagen final pesa ~250 MB y no vale la pena optimizar para 48h.

### 5.2 Configuración Coolify

**Volúmenes persistentes a declarar en la UI:**

| Mount point | Tipo |
|---|---|
| `/app/leads.db` | bind file |
| `/app/app/data/reports_output/` | bind dir |
| `/app/material/` | bind dir |

**Variables de entorno:**

```
SECRET_KEY=<random 64-char string>
ADMIN_USER=felipe
ADMIN_PASS=<random largo>
TOKEN_EXPIRY_HOURS=24
BASE_URL=https://taller-wox.fitlabs.dev
```

**DNS:** apuntar `taller-wox.fitlabs.dev` al IP del VPS de Coolify desde el panel de DNS del registrar. Coolify gestiona el cert Let's Encrypt automáticamente.

### 5.3 Workflow de actualización

- **Cambios de código** (endpoints, frontend, templates): commit + push → Coolify rebuildea + redeploy.
- **Cambios de los ZIPs descargables**: Felipe sube directo al volumen `/app/material/` vía SFTP o file manager de Coolify — **sin redeploy**.
- **Cambios de copy de las cards de descarga**: requieren redeploy (están hardcodeadas). Aceptable por ahora.

---

## 6. Datasets sintéticos que tengo que generar

Todos siguiendo los esquemas del SPEC §5 y §9. **Coherencia entre datasets es crítica.**

### 6.1 `app/data/historical_procedures.csv` (~50 filas)

Columnas exactas del SPEC §5.1. **Reglas de coherencia:**
- Al menos 4 filas con `member_name = Charlie Smith` y `relationship = Self`, con procedimientos y `date` consistentes con los `last_date` de `member_insights.json`:
  - `Annual Physical Exam` con `date = 2022-06-15`
  - `Dental Cleaning` con `date = 2023-01-10`
  - `Vision Exam` con `date = 2021-08-22`
  - `Blood Test - Cholesterol Panel` con `date = 2023-03-04`
- Resto de filas: otros member_names (Alice, Bob, Diana, Ethan) con relationships variados (Self, Spouse, Mother, Father, Son, Daughter), con procedimientos variados que cubren `preventive`, `surgery`, `diagnostic`.
- Costos realistas: X Ray $80-300, MRI $1500-3000, Annual Physical $200-400, Appendectomy $10k-30k, Dental Cleaning $80-200.
- `in_network` mezcla de true/false (~70/30).
- `member_plan` mezcla de los 3 planes del SPEC.

### 6.2 `app/data/available_procedures.csv` (~25-30 filas)

Columnas del SPEC §5.2. **Reglas:**
- Cubrir al menos estos `procedure`: `X Ray`, `MRI`, `CT Scan`, `Annual Physical Exam`, `Appendectomy`, `Dental Cleaning`, `Vision Exam`, `Blood Test`, `Angioplasty`, `Ultrasound`.
- Cada procedimiento aparece en 2-4 ubicaciones distintas (City Hospital, Regional Medical Center, Green Valley Clinic, Sunrise Health) con costos y distancias distintas.
- `gold_ppo_plan_accepted` true en ~80% de las filas (es el plan de Charlie Smith).
- Costos realistas y diferentes a los de `historical_procedures.csv` (uno es histórico, otro es catálogo).

### 6.3 `app/data/member_insights.json`

JSON estático basado en el SPEC §5.3, **con un ajuste obligatorio**: las fechas de `overdue_procedures` del SPEC están escritas con base ~2024 (ej. `last_date: "2022-06-15"` con `due_since_months: 22`). Hoy es 2026-05-12, así que esos cálculos están desfasados ~2 años. Hay que **re-calibrar** `last_date` y `due_since_months` para que sean coherentes con la fecha actual y el `recommended_frequency_months` de cada item. Las fechas resultantes también deben calzar con las filas correspondientes en `historical_procedures.csv` (sección 6.1).

Ejemplo de re-calibración para `Annual Physical Exam` (frecuencia recomendada: 12 meses, prioridad alta):
- `last_date`: ~24 meses atrás → `2024-05-15`
- `due_since_months`: 12 (debido hace 1 año)

### 6.4 `app/data/schedule_response.txt`

Texto estático tal cual el SPEC §5.4.

### 6.5 Archivos para endpoint 5 (reportes)

- `app/data/combined_email.txt` — email mezclado proveedor + paciente (ejemplo en SPEC §5.5)
- `app/data/provider_email.txt` — email solo del proveedor con jerga médica
- `app/data/aetna_email.txt` — email de aseguradora con EOB
- `app/data/aetna_claim_review_summary.csv` (~10 filas) — CPT codes con charged/allowed/plan_paid/patient_responsibility coherentes (el último iguala los anteriores menos los descuentos)

---

## 7. Estructura de carpetas final

```
taller-wox/
├── app/
│   ├── main.py                    # FastAPI app, mounts, CORS, lifespan
│   ├── frontend.py                # GET /, GET /descargas, POST /register, GET /download
│   ├── benefits_api.py            # endpoints 1-4
│   ├── reports_api.py             # endpoint 5
│   ├── admin.py                   # endpoints /admin/*
│   ├── db.py                      # SQLite, init schema, helpers
│   ├── security.py                # token sign/verify, basic auth, honeypot
│   ├── data/
│   │   ├── historical_procedures.csv
│   │   ├── available_procedures.csv
│   │   ├── member_insights.json
│   │   ├── schedule_response.txt
│   │   ├── combined_email.txt
│   │   ├── provider_email.txt
│   │   ├── aetna_email.txt
│   │   ├── aetna_claim_review_summary.csv
│   │   └── reports_output/        # generados por endpoint 5 (volumen persistente)
│   └── templates/
│       ├── index.html             # landing + registro
│       ├── descargas.html         # página post-registro
│       └── report.html            # wrapper del reporte (endpoint 5)
├── static/
│   ├── css/styles.css
│   ├── img/
│   │   ├── LogoFIT.png            # ya existe, comprimir
│   │   └── favicon.ico            # generar
│   └── js/app.js
├── material/                       # volumen persistente, Felipe sube los ZIPs
│   ├── taller-wox-tecnico.zip
│   └── taller-wox-funcional.zip
├── docs/
│   └── superpowers/
│       └── specs/
│           └── 2026-05-12-taller-wox-design.md  # este archivo
├── leads.db                        # volumen persistente
├── Dockerfile
├── requirements.txt
├── .env.example
└── README.md
```

---

## 8. Cuts explícitos (lo que NO se hace para el jueves)

- ❌ Email transaccional (SMTP)
- ❌ Admin UI / dashboard con gráficos
- ❌ ZIP "todo en uno" como tercera descarga
- ❌ ZIP-por-archivo individual
- ❌ Re-captcha (solo honeypot)
- ❌ Stats avanzados / analytics
- ❌ Multi-idioma (solo español)
- ❌ Editor visual de leads
- ❌ Rate limiting en `/api/*`
- ❌ Cleanup automático de reportes generados
- ❌ Conversión PPTX → PDF (Felipe arma los ZIPs)
- ❌ Logo IBM watsonx oficial (placeholder de texto hasta tener el oficial)
- ❌ Multi-stage Docker build
- ❌ Healthcheck custom (Coolify usa ping HTTP por defecto)
- ❌ Tests automatizados extensos (solo smoke tests manuales del SPEC §8)

---

## 9. Criterios de aceptación para el jueves 2026-05-14

Antes del taller, todo lo siguiente debe estar verde:

**Smoke tests manuales:**
- [ ] `GET https://taller-wox.fitlabs.dev/` devuelve 200 con HTML brandeado FIT
- [ ] Submit del form con datos válidos redirige a `/descargas?token=...`
- [ ] La página de descargas muestra las 2 cards y el saludo con el nombre
- [ ] `GET /download/taller-wox-tecnico.zip?token=...` descarga el ZIP correcto
- [ ] `GET /download/taller-wox-funcional.zip?token=...` descarga el ZIP correcto
- [ ] Token inválido → redirige a home con banner de error
- [ ] Email duplicado en form → re-emite token sin error

**API del taller (con `curl` desde laptop):**
- [ ] `POST /api/historical-procedures` con `{"filters":"[]","group_by":"[]"}` devuelve filas
- [ ] `POST /api/historical-procedures` con filtro `member_name = Charlie Smith` devuelve las 4 filas esperadas
- [ ] `POST /api/available-procedures` con filtro `procedure contains MRI` devuelve resultados
- [ ] `GET /api/member-insights` devuelve el objeto con `overdue_procedures` con 4 items
- [ ] `GET /api/schedule` devuelve el texto de scheduling
- [ ] `POST /api/reports/generate-report` con `{"layout_config":"[\"care_report\"]"}` devuelve un `public_url` que abre un HTML válido

**End-to-end con watsonx Orchestrate:**
- [ ] Importar `openapi-tools-spec.json` en un agente nuevo de watsonx funciona sin errores de TLS
- [ ] Prompt "¿Estoy atrasado en algún chequeo?" devuelve los 4 procedimientos overdue
- [ ] Prompt "¿Cuánto cuesta una resonancia magnética?" devuelve resultados de MRI

**Admin:**
- [ ] `GET /admin/leads.csv` con basic auth descarga CSV con los leads de prueba
- [ ] `GET /admin/stats` devuelve los conteos esperados

**Operacional:**
- [ ] Coolify rebuildea sin errores al hacer push
- [ ] Redeploy NO borra `leads.db` ni los ZIPs (volúmenes funcionando)
- [ ] HTTPS válido (curl al dominio sin warning de cert)

---

## 10. Riesgos identificados

| Riesgo | Mitigación |
|---|---|
| Cambio de DNS no propaga a tiempo | Configurar DNS lo antes posible (hoy mismo idealmente), TTL bajo |
| Volúmenes Coolify mal configurados → pérdida de datos en redeploy | Probar un redeploy de prueba antes del miércoles y verificar que `leads.db` sobrevive |
| Watsonx no puede llamar a la API por problema de TLS o CORS | Smoke test end-to-end con un agente de prueba el miércoles |
| Datasets sintéticos inconsistentes (Charlie Smith con datos que no calzan) | Validar cruzando `historical_procedures.csv` vs `member_insights.json` antes de deploy |
| Tamaño del PNG del logo (971 KB) impacta carga del landing | Comprimir a <100 KB con tinypng o similar antes del deploy |
| Bug en código del frontend tumba la API en vivo durante el taller | Política: no tocar código el día del taller. Si hay bug crítico en API, rollback inmediato a la versión anterior en Coolify |

---

## 11. Lo que NO está en este diseño y necesita decidirse después del jueves

- Persistencia de los HTMLs generados por endpoint 5 — política de retención (¿borrar después de N días?)
- Si vienen cohortes con datasets distintos, cómo soportar multi-cohort sin redeploy
- Si el admin necesita UI real (dashboard, gráficos)
- Si conviene separar API y frontend en dos servicios
- Si vale la pena agregar webhook a Slack/email cuando se registra un lead nuevo (lead capture activo)

---

**Fin del diseño. Siguiente paso:** invocar skill `writing-plans` para crear el plan de implementación paso a paso.
