Portal FastAPI + 5 endpoints REST para Bootcamp Agentic AI con watsonx Orchestrate (FactorIT). Single container, Coolify-ready. - Landing brandeado FIT con formulario de registro (honeypot anti-bot) - Tokens itsdangerous para descargas (24h expiry) - 5 endpoints API: historical/available procedures, member-insights, schedule, generate-report (Jinja2 + Plotly) - SQLite con upsert-on-email para leads + log de descargas - Admin endpoints (HTTP Basic): leads.json, leads.csv, stats - 23 tests pytest pasando - Dockerfile listo para Coolify con volúmenes persistentes (/app/leads.db, /app/app/data/reports_output, /app/material) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
32 KiB
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:
-
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).
-
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:
<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 |
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
# 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.URLSafeTimedSerializercon unSECRET_KEYdesde env. - El token contiene el email del lead.
- Expira a las 24 horas (configurable).
- Validar en cada descarga.
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):
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):
{
"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:
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):
{ "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.
{
"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.
{
"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:
{
"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:[ {"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):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:
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):
{ "public_url": "https://taller-wox.fitlabs.dev/api/reports/output/abc123def456.html" }
Response (500) si algo falla:
{ "detail": "Error generating report: {mensaje}" }
Template templates/report.html (wrapper del reporte generado):
<!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
# .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
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:
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:
# 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:
- Crear cuenta IBM Cloud trial.
- Importar
openapi-tools-spec.jsonen un agente nuevo en watsonx Orchestrate. - Hacer prompt: "¿Estoy atrasado en algún chequeo?" — debe llamar
/api/member-insightsy devolver los 4 procedimientos overdue. - Hacer prompt: "¿Cuánto cuesta una resonancia magnética?" — debe llamar
/api/available-procedurescon filtroprocedure 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 deAnnual Physical Exam,Dental Cleaning,Vision ExamyBlood Testcondateconsistente con loslast_datedemember_insights.json(los procedimientos overdue). - En
available_procedures.csv, losproceduredeben 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):
static/img/logo-factorit.svg— logo oficial FactorIT (Felipe ya lo tiene)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.icoyfavicon-32x32.png— versión simplificada del logo FIT
Meta tags para social sharing (OpenGraph):
<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 --reloadlevanta el server local enhttp://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/leadsrequiere basic auth y lista los registros- El
Dockerfilebuilda 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:
- Crear la estructura de carpetas (sección 2).
- Implementar
app/main.pycon FastAPI básico, mount de static y templates Jinja2. - Implementar registro + descarga (sección 4) — eso es el corazón del producto.
- Implementar los 5 endpoints (sección 5) — con datasets sintéticos pequeños primero.
- Implementar el landing HTML con branding (sección 3).
- Implementar la página de descargas (sección 3.4).
- Dockerizar (sección 7.1).
- Smoke tests (sección 8).
- Deploy a un proveedor (a definir por Felipe).
- Apuntar el DNS
taller-wox.fitlabs.deval 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.