# 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 ``` **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 `
FACTORIT · FIT