# SPEC — Portal taller-wox.fitlabs.dev

**Proyecto:** Sitio web + API para el "Bootcamp Agentic AI con watsonx Orchestrate" de FactorIT (FIT).
**Dominio:** `https://taller-wox.fitlabs.dev`
**Audiencia técnica de este documento:** un desarrollador (o Claude Code) que va a implementar TODO el sistema de cero.

---

## 0. Visión general

Construir un sitio web público + backend API que cumple **dos funciones**:

1. **Portal web brandeado FactorIT + watsonx Orchestrate** donde los alumnos del taller se registran (nombre, email, empresa) y, tras el registro, descargan el material del bootcamp (manual PDF, deck PPT, archivos del lab).

2. **5 endpoints REST** que son consumidos durante el taller por los agentes que los alumnos construyen en IBM watsonx Orchestrate. Estos endpoints se exponen bajo `https://taller-wox.fitlabs.dev/api/`.

El sitio debe poder hostearse en cualquier proveedor (Vercel, Railway, Render, Fly.io, VPS propio). Recomendado: un solo servicio FastAPI que sirva tanto el frontend (HTML estático + endpoints de registro/descarga) como la API del taller.

---

## 1. Stack técnico recomendado

| Capa | Tecnología | Justificación |
|---|---|---|
| Backend | Python 3.11+ con FastAPI + Uvicorn | Más rápido para los endpoints del taller (pandas para filtros) |
| Frontend | HTML + CSS + JS vanilla (sin framework) | Simple, una sola página, fácil de mantener |
| Persistencia | SQLite (file-based) | Solo se guardan registros de leads; no necesita Postgres |
| Almacenamiento de archivos | Carpeta local `./material/` servida como static | Los archivos pesan ~28 MB en total |
| HTML reports (endpoint 5) | Jinja2 + Plotly | Genera reportes y los hostea en `/api/reports/output/{id}.html` |
| Deploy | Railway / Render / Fly.io (con Dockerfile) | Cualquiera soporta FastAPI + volumen para SQLite |

**Dependencias Python:**
```
fastapi
uvicorn[standard]
pandas
jinja2
plotly
python-multipart
pydantic[email]
itsdangerous            # firma tokens de descarga
aiosmtplib              # email opcional (descarga vía link)
```

---

## 2. Estructura de carpetas esperada

```
taller-wox/
├── app/
│   ├── main.py                    # FastAPI app + mounts
│   ├── frontend.py                # rutas que sirven HTML
│   ├── registration.py            # POST /register, GET /download
│   ├── benefits_api.py            # endpoints 1-4 (AskBenefits)
│   ├── reports_api.py             # endpoint 5 (AskReporting)
│   ├── db.py                      # SQLite (leads.db)
│   ├── data/
│   │   ├── historical_procedures.csv     # dataset endpoint 1
│   │   ├── available_procedures.csv      # dataset endpoint 2
│   │   ├── member_insights.json          # endpoint 3 (estático)
│   │   ├── schedule_response.txt         # endpoint 4 (estático)
│   │   ├── combined_email.txt            # endpoint 5
│   │   ├── provider_email.txt            # endpoint 5
│   │   ├── aetna_email.txt               # endpoint 5
│   │   └── aetna_claim_review_summary.csv# endpoint 5
│   └── templates/
│       ├── index.html             # landing + registro
│       ├── descargas.html         # página de material (post-registro)
│       └── report.html            # template del reporte (endpoint 5)
├── static/
│   ├── css/
│   │   └── styles.css             # estilos del sitio
│   ├── img/
│   │   ├── logo-factorit.svg      # logo FIT
│   │   ├── logo-watsonx.svg       # logo watsonx Orchestrate
│   │   └── hero-bg.jpg            # imagen de fondo (opcional)
│   └── js/
│       └── app.js
├── material/                       # archivos descargables
│   ├── Manual_Alumno_Bootcamp_FIT.docx
│   ├── Bootcamp_Agentic_AI_FIT.pptx
│   ├── openapi-tools-spec.json
│   ├── openapi-tools-spec.yaml
│   ├── dental_benefits_summary.pdf
│   ├── openapi-tools-report.json
│   ├── main-desk-concierge-action.json
│   └── bank-assistant-pack.zip
├── leads.db                        # SQLite (creado en runtime)
├── Dockerfile
├── requirements.txt
└── README.md
```

---

## 3. El sitio web (frontend)

### 3.1 Branding y look & feel

**Paleta de colores (FIT corporativo):**
- Navy principal: `#0A1F44`
- Azul: `#1E4FA8`
- Cyan accent: `#00B5D8`
- Naranja highlight: `#FF7A00`
- Crema fondo claro: `#F5F7FA`
- Texto oscuro: `#1A1A1A`
- Gris secundario: `#5A6473`

**Tipografía:** Inter o system-ui (sans-serif). Cargar desde Google Fonts:
```html
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
```

**Logos a mostrar (en el header):**
- Logo FactorIT (izquierda) — ya está en `static/img/logo-factorit.svg`
- Logo IBM watsonx Orchestrate (derecha, más pequeño, como "powered by") — bajar de https://www.ibm.com/brand/watsonx (o usar un placeholder hasta tener uno oficial)

### 3.2 Página principal `GET /`

Estructura visual (una sola página, scroll vertical):

**Sección 1 — Hero (pantalla completa, fondo navy con elementos decorativos cyan/naranja):**
- Logo FactorIT arriba a la izquierda
- Eyebrow: "FACTORIT · FIT"
- Título grande blanco: "Bootcamp Agentic AI con watsonx Orchestrate"
- Subtítulo crema italic: "Construye tu primer agente de IA en 4 horas"
- Sello esquina derecha: "powered by IBM watsonx Orchestrate" (logo IBM pequeño)
- Botón CTA naranja grande: "Acceder al material →" (scroll smooth a sección 4)

**Sección 2 — ¿Qué vas a construir? (3 cards horizontales, fondo crema):**
- Card 1: "Tu primer agente" — icono engranaje navy, descripción 2 líneas
- Card 2: "Multi-agente con RAG" — icono libro, descripción
- Card 3: "Reportes y APIs" — icono gráfico, descripción

**Sección 3 — El taller en números (4 stats con números grandes naranja):**
- "4h" Duración
- "6" Módulos
- "0" Líneas de código (en el módulo 1-4)
- "100%" Hands-on

**Sección 4 — Material del taller (la sección donde está el formulario de registro):**
- Título: "Descarga todo el material"
- Texto: "Registra tus datos para acceder al kit completo del bootcamp."
- Formulario con 3 campos (ver 3.3)
- Tras enviar, redirige a `/descargas?token=XXX`

**Sección 5 — Footer (fondo navy):**
- Logos FIT y watsonx
- "Material del bootcamp · taller-wox.fitlabs.dev"
- Link "github.com/leozangulo/agentic-bootcamp"
- "© 2026 FactorIT — Todos los derechos reservados"

### 3.3 Formulario de registro

**Campos:**
| Campo | Tipo | Validación | Required |
|---|---|---|---|
| `nombre` | text | min 2 chars, max 80 | ✓ |
| `email` | email | regex email válido | ✓ |
| `empresa` | text | min 2 chars, max 100 | ✓ |
| `consentimiento` | checkbox | debe estar marcado | ✓ |

Texto del checkbox: "Acepto que FactorIT use mis datos para enviarme información del bootcamp y comunicaciones futuras. No spam — solo lo importante."

**Validación cliente:** HTML5 + JS antes de submit.

**Action:** `POST /register` (ver 4.1).

### 3.4 Página de descargas `GET /descargas?token=XXX`

Validar el token (firma con `itsdangerous`, ver 4.1). Si es inválido o expirado, redirigir a `/?error=token-invalido`.

Si es válido, mostrar:

- Saludo: "¡Hola {nombre}! Acá tienes todo el material."
- Grilla 2x4 (o 3x3 según ancho) con cards de descarga, cada una con:
  - Icono del tipo de archivo (PDF, Word, PPT, JSON, ZIP)
  - Nombre del archivo
  - Descripción breve (1 línea)
  - Tamaño en MB
  - Botón "Descargar" naranja

**Cards a mostrar:**

| # | Archivo | Descripción |
|---|---|---|
| 1 | `Manual_Alumno_Bootcamp_FIT.docx` | Manual paso a paso del taller (26 páginas) |
| 2 | `Bootcamp_Agentic_AI_FIT.pptx` | Slides del bootcamp (46 slides) |
| 3 | `openapi-tools-spec.json` | API principal de AskBenefits |
| 4 | `openapi-tools-spec.yaml` | Misma API en YAML (backup) |
| 5 | `dental_benefits_summary.pdf` | PDF para Knowledge Base de AskDental |
| 6 | `openapi-tools-report.json` | API de AskReporting |
| 7 | `main-desk-concierge-action.json` | Acción watsonx Assistant (opcional) |
| 8 | `bank-assistant-pack.zip` | YAMLs + tools Python del ADK |

**Bonus card (opcional, si quieres ofrecer todo en un solo download):**
- 9 | `taller-wox-archivos.zip` | Todo el material en un solo zip (28 MB)

Cada botón "Descargar" hace `GET /download/{filename}?token=XXX`.

### 3.5 Estilos críticos de CSS

- Mobile-first (breakpoint a 768px)
- Hero ocupa 100vh en desktop, 80vh en mobile
- Cards: border-radius 12px, sombra suave, hover: lift 4px + sombra más fuerte
- Botones primarios: fondo naranja `#FF7A00`, hover: `#E66A00`, border-radius 8px
- Botones secundarios: outline cyan
- Animaciones: solo fade-in al scroll (intersection observer), nada elaborado

---

## 4. Backend (FastAPI)

### 4.1 Rutas del frontend y registro

```python
# rutas que sirven HTML
GET  /                      → renderiza templates/index.html
GET  /descargas             → valida token, renderiza templates/descargas.html
GET  /static/*              → archivos estáticos (CSS, JS, imgs, logos)

# registro y descarga
POST /register              → recibe form, guarda lead en SQLite, devuelve {token, redirect_url}
GET  /download/{filename}   → valida token query param, devuelve archivo de ./material/
```

**Lógica del token:**
- Usar `itsdangerous.URLSafeTimedSerializer` con un `SECRET_KEY` desde env.
- El token contiene el email del lead.
- Expira a las **24 horas** (configurable).
- Validar en cada descarga.

```python
from itsdangerous import URLSafeTimedSerializer
serializer = URLSafeTimedSerializer(SECRET_KEY)
token = serializer.dumps({"email": email})
# validar:
data = serializer.loads(token, max_age=86400)  # 24h
```

**Esquema SQLite (`leads.db`):**
```sql
CREATE TABLE IF NOT EXISTS leads (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    nombre      TEXT NOT NULL,
    email       TEXT NOT NULL,
    empresa     TEXT NOT NULL,
    ip          TEXT,
    user_agent  TEXT,
    consent     INTEGER NOT NULL DEFAULT 0,
    created_at  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
);
```

Registrar cada descarga en `downloads` para tener métricas.

### 4.2 Endpoint admin (opcional, protegido con HTTP Basic)

```
GET  /admin/leads      → lista todos los leads (JSON, requiere basic auth con ADMIN_USER/ADMIN_PASS desde env)
GET  /admin/stats      → conteo de registros, descargas por archivo, top empresas
GET  /admin/leads.csv  → exporta leads en CSV
```

Útil para que el equipo comercial pueda hacer follow-up.

---

## 5. Los 5 endpoints del taller

Los **agentes que el alumno construye en watsonx Orchestrate** llamarán estos endpoints. Los specs OpenAPI están en `material/openapi-tools-spec.json` y `material/openapi-tools-report.json`.

### 5.1 Endpoint 1 — `POST /api/historical-procedures`

**Para qué sirve:** responde "¿qué procedimientos he tenido?", "¿cuánto pagué por X?". Es un dataset histórico filtrable.

**Request body** (JSON):
```json
{
  "filters": "[{\"column\": \"member_name\", \"operator\": \"equals\", \"value\": \"Alice\"}]",
  "group_by": "[\"relationship\"]"
}
```

⚠️ **Importante:** `filters` y `group_by` llegan como **strings JSON-encoded**, no como objetos directos. Decodificar con `json.loads()` dentro.

**Operadores soportados:** `equals`, `ne`, `contains`, `gt`, `lt`, `ge`, `le`.

**Dataset a servir** (CSV en `app/data/historical_procedures.csv`, ~50 filas inventadas):

Columnas exactas (todas requeridas):
| Columna | Tipo | Ejemplos |
|---|---|---|
| `member_name` | string | Alice, Bob, Charlie, Diana, Ethan |
| `relationship` | string | Self, Spouse, Mother, Father, Son, Daughter |
| `age` | int | 12, 25, 42, 65 |
| `gender` | string | Female, Male |
| `procedure` | string | Annual Physical Exam, Appendectomy, CT Scan, X Ray, Dental Cleaning, Vision Exam, MRI, Blood Test |
| `procedure_type` | string | preventive, surgery, diagnostic |
| `location` | string | City Hospital, Green Valley Clinic, Sunrise Health, Regional Medical Center |
| `date` | YYYY-MM-DD | 2024-04-28, 2023-05-02 |
| `in_network` | bool | true, false |
| `member_plan` | string | Gold PPO, Family Plan - Silver EPO, Bronze HDHP |
| `accepted_plans` | string | "Gold PPO, Medicare Advantage" |
| `cost_facility` | float | 29.4, 48.02 |
| `cost_physician` | float | 199.09, 128.9 |
| `cost_anesthesia` | float | 0.0, 2024.21 |
| `cost_medication` | float | 4.19, 5.53 |
| `total_cost` | float | 232.68, 243.3 |
| `facility_rating` | float | 4.7, 4.5 |
| `notes` | string | "Annual Physical Exam performed at City Hospital." |

**Lógica del handler:**
```python
import pandas as pd, json

df = pd.read_csv("app/data/historical_procedures.csv")

@app.post("/api/historical-procedures")
def historical_procedures(payload: dict):
    filters = json.loads(payload.get("filters", "[]"))
    group_by = json.loads(payload.get("group_by", "[]"))

    result = df.copy()
    for f in filters:
        col, op, val = f["column"], f["operator"], f["value"]
        if op == "equals":   result = result[result[col] == val]
        elif op == "ne":     result = result[result[col] != val]
        elif op == "contains": result = result[result[col].astype(str).str.contains(str(val), case=False, na=False)]
        elif op == "gt":     result = result[result[col] > val]
        elif op == "lt":     result = result[result[col] < val]
        elif op == "ge":     result = result[result[col] >= val]
        elif op == "le":     result = result[result[col] <= val]

    if group_by:
        # agregaciones por columna numérica, conteo por las demás
        numeric_cols = result.select_dtypes(include='number').columns.tolist()
        result = result.groupby(group_by)[numeric_cols].mean().reset_index()

    return {"result": result.to_dict(orient="records")}
```

**Response (200):**
```json
{ "result": [ { "member_name": "Alice", "age": 42, ... }, ... ] }
```

### 5.2 Endpoint 2 — `POST /api/available-procedures`

**Para qué sirve:** responde "¿cuánto cuesta una radiografía?", "¿dónde puedo hacerme una resonancia magnética?". Es el catálogo de procedimientos disponibles con costos por proveedor.

**Request body:** idéntico al endpoint 1 (`filters` + `group_by`).

**Dataset** (CSV en `app/data/available_procedures.csv`, ~25-30 filas):

| Columna | Tipo | Ejemplos |
|---|---|---|
| `procedure` | string | Angioplasty, Annual Physical Exam, Appendectomy, X Ray, MRI, CT Scan |
| `location` | string | City Hospital, Regional Medical Center, Green Valley Clinic |
| `facility_rating` | float | 4.7, 4.3, 4.5 |
| `distance_miles` | float | 5.2, 12.6, 1.2 |
| `gold_ppo_plan_accepted` | bool | true, false |
| `silver_epo_plan_accepted` | bool | true, false |
| `accepted_plans` | string | "Gold PPO, Medicare Advantage" |
| `cost_facility` | float | 9432.8 |
| `cost_physician` | float | 4774.57 |
| `cost_anesthesia` | float | 1894.37 |
| `cost_medication` | float | 834.8 |
| `total_cost` | float | 16936.54 |

**Lógica:** misma función que el endpoint 1, solo cambia el CSV de entrada.

**Response (200):** `{ "result": [ ... ] }`

### 5.3 Endpoint 3 — `GET /api/member-insights`

**Para qué sirve:** responde "¿qué chequeos tengo atrasados?", "¿cuál es mi plan?", "¿cómo accedo a mi formulario 1095?". Devuelve el perfil del afiliado de demo.

**Sin parámetros.**

**Response (200):** SIEMPRE el mismo objeto. Guardarlo como `app/data/member_insights.json` y servirlo tal cual.

```json
{
  "result": {
    "member": {
      "name": "Charlie Smith",
      "date_of_birth": "2013-03-04",
      "plan": "Gold PPO",
      "member_id": "CS-001-2024"
    },
    "medical_plan": {
      "name": "Gold PPO",
      "deductible": 1500,
      "deductible_met": 850,
      "out_of_pocket_max": 6000,
      "out_of_pocket_met": 1200,
      "coinsurance": "20%",
      "primary_care_copay": 25,
      "specialist_copay": 50,
      "emergency_room_copay": 250
    },
    "pharmacy_plan": {
      "tier_1_copay": 10,
      "tier_2_copay": 30,
      "tier_3_copay": 60,
      "tier_4_coinsurance": "30%",
      "mail_order_available": true
    },
    "mental_health": {
      "covered_visits_per_year": 20,
      "visits_used": 4,
      "telehealth": true,
      "in_network_providers": "https://taller-wox.fitlabs.dev/docs/mental-health-providers"
    },
    "wellness": {
      "gym_reimbursement_annual": 300,
      "gym_reimbursement_used": 150,
      "annual_checkup_covered": true,
      "preventive_care_100_percent": true,
      "flu_shot_covered": true
    },
    "tax_documents": {
      "form_1095_available": true,
      "form_1095_url": "https://taller-wox.fitlabs.dev/docs/1095-2024.pdf",
      "instructions": "Tu formulario 1095 está disponible en el portal del afiliado bajo Documentos > Impuestos. Si no lo puedes acceder, llama al 1-800-FIT-CARE."
    },
    "overdue_procedures": [
      {
        "procedure": "Annual Physical Exam",
        "last_date": "2022-06-15",
        "recommended_frequency_months": 12,
        "due_since_months": 22,
        "priority": "high"
      },
      {
        "procedure": "Dental Cleaning",
        "last_date": "2023-01-10",
        "recommended_frequency_months": 6,
        "due_since_months": 15,
        "priority": "medium"
      },
      {
        "procedure": "Vision Exam",
        "last_date": "2021-08-22",
        "recommended_frequency_months": 24,
        "due_since_months": 32,
        "priority": "medium"
      },
      {
        "procedure": "Blood Test - Cholesterol Panel",
        "last_date": "2023-03-04",
        "recommended_frequency_months": 12,
        "due_since_months": 13,
        "priority": "low"
      }
    ]
  }
}
```

### 5.4 Endpoint 4 — `GET /api/schedule`

**Para qué sirve:** el agente lo invoca cuando el usuario dice "agéndame una cita". No agenda nada real — devuelve una guía/instrucciones que el agente lee y reformula al usuario.

**Sin parámetros.**

**Response (200):** SIEMPRE el mismo string. Guardar el texto en `app/data/schedule_response.txt`.

```json
{
  "result": "Para agendar una cita médica, sigue estos pasos: 1) Confirma con el afiliado el día y hora preferidos, y el tipo de procedimiento. 2) Verifica que el procedimiento esté cubierto por su plan (Gold PPO en este caso). 3) Llama al sistema de agendamiento de FIT Care al 1-800-FIT-CARE o entra al portal en https://taller-wox.fitlabs.dev/agenda. 4) Indica el procedimiento, el proveedor preferido (City Hospital, Green Valley Clinic, Sunrise Health o Regional Medical Center) y la fecha. 5) Confirma la cita y registra el número de confirmación. 6) Envía recordatorios automáticos al afiliado 24h y 1h antes. Recuerda: si el procedimiento requiere autorización previa, gestiónala antes de confirmar la cita."
}
```

### 5.5 Endpoint 5 — `POST /api/reports/generate-report`

**Para qué sirve:** el agente AskReporting lo llama cuando el usuario dice "create a care report". Genera un HTML compuesto, lo guarda en disco y devuelve la URL pública.

**Request body:**
```json
{
  "layout_config": "[\"care_report\", {\"element_type\": \"header\", \"parameters\": {\"title\": \"End of Report\"}}]"
}
```

`layout_config` es un **string JSON-encoded** que contiene una lista de elementos. Decodificar con `json.loads()`.

Cada elemento puede ser:

**(a) Un string preset:**
- `"care_report"` — equivale a este layout completo:
  ```python
  [
    {"element_type": "header", "parameters": {"title": "Care Report"}},
    {"element_type": "overview", "parameters": {
      "prompt": "Summarize this email exchange in 3 bullet points: ",
      "text_file": "./data/combined_email.txt",
      "title": "Customer Overview"
    }},
    {"element_type": "claim_review_chart", "parameters": {}},
    {"element_type": "table", "parameters": {
      "csv_file": "./data/aetna_claim_review_summary.csv",
      "title": "Claim Review Summary"
    }}
  ]
  ```

**(b) Un objeto con `element_type` y `parameters`:**

| `element_type` | Parámetros | Hace |
|---|---|---|
| `header` | `title` (str) | Renderiza `<h2>{title}</h2>` centrado |
| `overview` | `prompt`, `text_file`, `title` | Lee el `.txt`, opcionalmente le aplica un prompt simple (puedes hardcodear un resumen pre-armado por archivo para evitar dependencia de LLM), renderiza como markdown convertido a HTML |
| `claim_review_chart` | (ninguno) | Renderiza un gráfico de barras Plotly con datos sintéticos de CPT codes vs charged/allowed/patient_responsibility |
| `table` | `csv_file`, `title` | Lee el CSV y lo renderiza como tabla HTML con styling Plotly |

**Archivos en `./app/data/` que tienes que crear:**

- **`combined_email.txt`** (~10 líneas): email mezclado proveedor + paciente. Ej:
  ```
  From: dr.martinez@cityhospital.com
  Subject: Follow-up after appointment

  Hi Charlie,

  Following our visit last week, I'm recommending a follow-up CT scan
  to confirm the diagnosis. Please schedule within the next 2 weeks.

  ---

  From: charlie.smith@gmail.com
  Subject: Re: Follow-up

  Thanks Dr. Martinez. Will the CT scan be covered by my Gold PPO plan?
  Also, can I get a copy of the lab results from last visit?
  ```

- **`provider_email.txt`** (~8 líneas): email solo del proveedor con jerga médica.
  ```
  Patient presented with bilateral lower quadrant tenderness, WBC 14k,
  CRP elevated. Differential includes appendicitis vs diverticulitis.
  Recommending CT abdomen/pelvis with contrast STAT.
  CPT 74177 ordered. Pre-auth obtained: AUTH-2024-8821.
  ```

- **`aetna_email.txt`** (~8 líneas): email de aseguradora con EOB.
  ```
  Claim #AET-2024-9912 has been processed.
  Service date: 2024-04-15
  Provider: City Hospital
  Total billed: $1,847.50
  Plan allowance: $1,200.00
  Plan paid: $960.00
  Patient responsibility (20% coinsurance after deductible): $240.00
  ```

- **`aetna_claim_review_summary.csv`** (~10 filas):
  ```csv
  Date,CPT_Code,Description,Charged_Amount,Allowed_Amount,Plan_Paid,Patient_Responsibility
  2024-04-15,74177,CT Abdomen/Pelvis w/contrast,1847.50,1200.00,960.00,240.00
  2024-03-22,99213,Office visit established patient,185.00,120.00,96.00,24.00
  2024-02-10,80061,Lipid panel,82.00,55.00,44.00,11.00
  ...
  ```

**Lógica del handler:**
```python
import json, uuid
from pathlib import Path

OUTPUT_DIR = Path("app/data/reports_output")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)

@app.post("/api/reports/generate-report")
def generate_report(payload: dict):
    layout = json.loads(payload["layout_config"])

    # expandir presets
    expanded = []
    for item in layout:
        if item == "care_report":
            expanded.extend(CARE_REPORT_PRESET)  # constante definida arriba
        else:
            expanded.append(item)

    # renderizar cada elemento
    html_parts = []
    for el in expanded:
        kind = el["element_type"]
        params = el.get("parameters", {})
        if kind == "header":
            html_parts.append(f"<h2 style='text-align:center;'>{params['title']}</h2>")
        elif kind == "overview":
            html_parts.append(render_overview(params))
        elif kind == "claim_review_chart":
            html_parts.append(render_claim_chart())
        elif kind == "table":
            html_parts.append(render_table(params))

    # combinar en un HTML completo (usar templates/report.html como wrapper)
    report_id = uuid.uuid4().hex[:12]
    output_path = OUTPUT_DIR / f"{report_id}.html"
    output_path.write_text(wrap_in_template("\n".join(html_parts)))

    public_url = f"https://taller-wox.fitlabs.dev/api/reports/output/{report_id}.html"
    return {"public_url": public_url}

# servir los HTMLs generados como estáticos
app.mount("/api/reports/output", StaticFiles(directory="app/data/reports_output"), name="reports")
```

**Response (200):**
```json
{ "public_url": "https://taller-wox.fitlabs.dev/api/reports/output/abc123def456.html" }
```

**Response (500) si algo falla:**
```json
{ "detail": "Error generating report: {mensaje}" }
```

**Template `templates/report.html`** (wrapper del reporte generado):
```html
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <title>Care Report</title>
  <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
  <style>
    body { font-family: Inter, sans-serif; max-width: 960px; margin: 40px auto; padding: 20px; color: #1A1A1A; }
    h2 { color: #0A1F44; border-bottom: 3px solid #FF7A00; padding-bottom: 8px; }
    table { width: 100%; border-collapse: collapse; margin: 16px 0; }
    th { background: #0A1F44; color: white; padding: 10px; }
    td { padding: 8px; border-bottom: 1px solid #D8DEE5; }
  </style>
</head>
<body>
  <header>
    <p style="color:#00B5D8; font-weight:700;">FACTORIT · FIT</p>
  </header>
  {{ content | safe }}
  <footer style="margin-top:40px; color:#5A6473; font-size:13px;">
    Generado por AskReporting · taller-wox.fitlabs.dev
  </footer>
</body>
</html>
```

---

## 6. Configuración por variables de entorno

```bash
# .env.example
SECRET_KEY=cambiar-esto-en-prod-string-largo-aleatorio
ADMIN_USER=admin
ADMIN_PASS=cambiar-esto
TOKEN_EXPIRY_HOURS=24
BASE_URL=https://taller-wox.fitlabs.dev
# Opcional, para mandar email de confirmación al registrarse
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=bootcamp@fitlabs.dev
```

---

## 7. Deployment

### 7.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/
COPY material/ ./material/

# crear directorios runtime
RUN mkdir -p app/data/reports_output

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

### 7.2 DNS

Apuntar `taller-wox.fitlabs.dev` (registro A o CNAME) al hosting elegido (Railway/Render/etc.).

### 7.3 HTTPS

Obligatorio. Usar el SSL automático del proveedor o Let's Encrypt si es VPS propio. watsonx Orchestrate **rechaza** llamar HTTPS con certificados inválidos.

### 7.4 CORS

Los endpoints `/api/*` deben permitir requests desde `https://*.ibm.com` (donde corre watsonx Orchestrate). Configurar:

```python
from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # o más restrictivo: ["https://us-south.dl.watson-orchestrate.cloud.ibm.com"]
    allow_credentials=True,
    allow_methods=["GET", "POST"],
    allow_headers=["*"],
)
```

---

## 8. Testing manual antes de publicar

Una vez todo desplegado, verificar:

```bash
# Frontend
curl https://taller-wox.fitlabs.dev/                  # debe devolver HTML del landing
curl -X POST https://taller-wox.fitlabs.dev/register \
  -d "nombre=Test&email=test@test.com&empresa=ACME&consentimiento=on"
# debería devolver {"token":"...","redirect_url":"/descargas?token=..."}

# Descargas (con token válido)
curl -I "https://taller-wox.fitlabs.dev/download/Manual_Alumno_Bootcamp_FIT.docx?token=XXX"
# debe devolver 200 con Content-Type docx

# Endpoints del taller
curl -X POST https://taller-wox.fitlabs.dev/api/historical-procedures \
  -H "Content-Type: application/json" \
  -d '{"filters":"[]","group_by":"[]"}'
# debe devolver {"result":[...]}

curl https://taller-wox.fitlabs.dev/api/member-insights
# debe devolver el objeto con member, plan, overdue_procedures

curl https://taller-wox.fitlabs.dev/api/schedule
# debe devolver el texto de scheduling

curl -X POST https://taller-wox.fitlabs.dev/api/reports/generate-report \
  -H "Content-Type: application/json" \
  -d '{"layout_config":"[\"care_report\"]"}'
# debe devolver {"public_url":"..."}
```

**Test end-to-end del taller:**
1. Crear cuenta IBM Cloud trial.
2. Importar `openapi-tools-spec.json` en un agente nuevo en watsonx Orchestrate.
3. Hacer prompt: "¿Estoy atrasado en algún chequeo?" — debe llamar `/api/member-insights` y devolver los 4 procedimientos overdue.
4. Hacer prompt: "¿Cuánto cuesta una resonancia magnética?" — debe llamar `/api/available-procedures` con filtro `procedure contains MRI`.

---

## 9. Datos sintéticos — orientación

Los datasets CSV los inventas pero tienen que ser **coherentes entre sí**:

- En `historical_procedures.csv`, el afiliado principal (`member_name = Charlie Smith`) debería tener filas de `Annual Physical Exam`, `Dental Cleaning`, `Vision Exam` y `Blood Test` con `date` consistente con los `last_date` de `member_insights.json` (los procedimientos overdue).
- En `available_procedures.csv`, los `procedure` deben incluir al menos los procedimientos preguntables: `X Ray`, `MRI`, `CT Scan`, `Annual Physical Exam`, `Appendectomy`, `Dental Cleaning`, `Vision Exam`.
- Los costos en ambos datasets pueden diferir (uno es histórico, otro es catálogo de oferta).

**Importante:** los costos deben verse realistas. Una radiografía cuesta ~$80-300, una resonancia ~$1500-3000, una apendicectomía ~$10000-30000.

---

## 10. Logos y branding

**Logos a conseguir (Felipe los provee al desarrollador):**
1. `static/img/logo-factorit.svg` — logo oficial FactorIT (Felipe ya lo tiene)
2. `static/img/logo-watsonx.svg` — logo "IBM watsonx Orchestrate" oficial (bajar de https://www.ibm.com/brand/ o usar texto estilizado mientras tanto)

**Favicon:**
- `static/img/favicon.ico` y `favicon-32x32.png` — versión simplificada del logo FIT

**Meta tags para social sharing (OpenGraph):**
```html
<meta property="og:title" content="Bootcamp Agentic AI — watsonx Orchestrate | FactorIT">
<meta property="og:description" content="Construye tu primer agente de IA en 4 horas con IBM watsonx Orchestrate. Material completo del bootcamp para descarga.">
<meta property="og:image" content="https://taller-wox.fitlabs.dev/static/img/og-image.png">
<meta property="og:url" content="https://taller-wox.fitlabs.dev/">
```

---

## 11. Checklist de entrega para Claude Code

Cuando Claude Code termine, debe poder confirmar:

- [ ] El comando `uvicorn app.main:app --reload` levanta el server local en `http://localhost:8000`
- [ ] `GET /` muestra el landing brandeado FIT con formulario
- [ ] El formulario valida los 3 campos + checkbox y guarda en `leads.db`
- [ ] Tras submit redirige a `/descargas?token=...`
- [ ] La página de descargas muestra las 8-9 cards de archivos
- [ ] Cada botón "Descargar" baja el archivo correcto desde `material/`
- [ ] Los 5 endpoints responden correctamente con curl (ver sección 8)
- [ ] `GET /admin/leads` requiere basic auth y lista los registros
- [ ] El `Dockerfile` builda sin errores: `docker build -t taller-wox .`
- [ ] El container corre: `docker run -p 8000:8000 -v $(pwd)/leads.db:/app/leads.db taller-wox`

---

## 12. Lo que NO está en el alcance (para evitar scope creep)

- ❌ Editor visual de leads — solo lectura JSON/CSV.
- ❌ Recuperación de password — no hay password, solo email para descarga.
- ❌ Email transaccional obligatorio — opcional (SMTP en env).
- ❌ Analytics avanzados — solo conteos simples en `/admin/stats`.
- ❌ Multi-idioma — solo español.
- ❌ Re-captcha — basta con honeypot field invisible en el formulario.

---

## 13. Resumen ejecutivo para arrancar

**Lo primero que Claude Code debe hacer:**

1. Crear la estructura de carpetas (sección 2).
2. Implementar `app/main.py` con FastAPI básico, mount de static y templates Jinja2.
3. Implementar registro + descarga (sección 4) — eso es el corazón del producto.
4. Implementar los 5 endpoints (sección 5) — con datasets sintéticos pequeños primero.
5. Implementar el landing HTML con branding (sección 3).
6. Implementar la página de descargas (sección 3.4).
7. Dockerizar (sección 7.1).
8. Smoke tests (sección 8).
9. Deploy a un proveedor (a definir por Felipe).
10. Apuntar el DNS `taller-wox.fitlabs.dev` al deployment.

**Tiempo estimado total para un desarrollador senior:** 1-1.5 días de trabajo enfocado.

---

**Fin del SPEC. Cualquier ambigüedad → preguntar a Felipe antes de implementar.**
