commit 041e2325e73cbe2ac12b761d88847ba12aa703e9 Author: farentsen Date: Wed May 13 03:01:44 2026 +0000 feat: initial implementation taller-wox.fitlabs.dev 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) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..799f28d --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +SECRET_KEY=replace-me-with-a-long-random-string +ADMIN_USER=admin +ADMIN_PASS=fit2026 +TOKEN_EXPIRY_HOURS=24 +BASE_URL=https://taller-wox.fitlabs.dev +MATERIAL_DIR=./material +DB_PATH=./leads.db +REPORTS_OUTPUT_DIR=./app/data/reports_output diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..30a567b --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.venv/ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +.env +leads.db +leads.db-journal +app/data/reports_output/*.html +material/*.zip +.DS_Store +*.egg-info/ +dist/ +build/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..201aae9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ +COPY static/ ./static/ + +RUN mkdir -p app/data/reports_output material + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -sf http://localhost:8000/health || exit 1 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d365aa2 --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# taller-wox.fitlabs.dev + +Portal + API para el **Bootcamp Agentic AI con watsonx Orchestrate** de FactorIT. + +- **Landing público:** `/` +- **Registro + descargas:** `/register`, `/descargas?token=…`, `/download/{file}?token=…` +- **API del taller** (consumida por agentes de watsonx Orchestrate): + - `POST /api/historical-procedures` + - `POST /api/available-procedures` + - `GET /api/member-insights` + - `GET /api/schedule` + - `POST /api/reports/generate-report` +- **Admin** (HTTP Basic): `/admin/leads.json`, `/admin/leads.csv`, `/admin/stats` + +## Local dev + +```bash +python3.11 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +uvicorn app.main:app --reload --port 8000 +``` + +Visitar `http://localhost:8000/`. + +Tests: +```bash +pytest -v +``` + +## Deploy en Coolify + +1. Crear nueva aplicación → tipo **Dockerfile** → source Gitea → repo `farentsen/taller-wox`. +2. FQDN: `taller-wox.fitlabs.dev`. HTTPS automático con Let's Encrypt. +3. **Volúmenes persistentes:** + - `/app/leads.db` (bind file) + - `/app/app/data/reports_output` (bind dir) + - `/app/material` (bind dir) +4. **Variables de entorno:** + - `SECRET_KEY` (string aleatorio largo) + - `ADMIN_USER=admin` + - `ADMIN_PASS=fit2026` + - `BASE_URL=https://taller-wox.fitlabs.dev` + - `TOKEN_EXPIRY_HOURS=24` +5. Subir los 2 ZIPs (`taller-wox-tecnico.zip`, `taller-wox-funcional.zip`) al volumen `/app/material/` vía SFTP o el file manager de Coolify (sin redeploy). +6. Deploy → Coolify buildea + arranca. + +## Estructura + +``` +app/ + main.py FastAPI bootstrap + config.py env vars + db.py SQLite + security.py tokens + basic auth + frontend.py / · /register · /descargas · /download + benefits_api.py endpoints 1-4 + reports_api.py endpoint 5 + admin.py /admin/* + data/ datasets de los endpoints + templates/ jinja2 +static/ CSS, JS, imágenes +material/ ZIPs descargables (volumen persistente en prod) +tests/ pytest +Dockerfile +``` + +## Spec y diseño +- Spec: `SPEC_taller_wox_fitlabs.md` +- Diseño: `docs/superpowers/specs/2026-05-12-taller-wox-design.md` +- Plan: `docs/superpowers/plans/2026-05-12-taller-wox.md` diff --git a/SPEC_taller_wox_fitlabs.md b/SPEC_taller_wox_fitlabs.md new file mode 100644 index 0000000..52b4c14 --- /dev/null +++ b/SPEC_taller_wox_fitlabs.md @@ -0,0 +1,848 @@ +# 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 `

{title}

` 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"

{params['title']}

") + 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 + + + + + Care Report + + + + +
+

FACTORIT · FIT

+
+ {{ content | safe }} +
+ Generado por AskReporting · taller-wox.fitlabs.dev +
+ + +``` + +--- + +## 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 + + + + +``` + +--- + +## 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.** diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/admin.py b/app/admin.py new file mode 100644 index 0000000..a5d7c0c --- /dev/null +++ b/app/admin.py @@ -0,0 +1,42 @@ +import csv +import io + +from fastapi import APIRouter, Depends, Query +from fastapi.responses import Response + +from app.db import list_leads, stats +from app.security import require_admin + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +@router.get("/leads.json") +def admin_leads_json( + _user: str = Depends(require_admin), + limit: int = Query(100, ge=1, le=1000), + offset: int = Query(0, ge=0), +): + return list_leads(limit=limit, offset=offset) + + +@router.get("/leads.csv") +def admin_leads_csv(_user: str = Depends(require_admin)): + rows = list_leads(limit=10_000, offset=0) + buf = io.StringIO() + if rows: + writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys())) + writer.writeheader() + writer.writerows(rows) + else: + buf.write("(no leads)\n") + payload = "" + buf.getvalue() + return Response( + content=payload, + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": 'attachment; filename="leads.csv"'}, + ) + + +@router.get("/stats") +def admin_stats(_user: str = Depends(require_admin)): + return stats() diff --git a/app/benefits_api.py b/app/benefits_api.py new file mode 100644 index 0000000..f376103 --- /dev/null +++ b/app/benefits_api.py @@ -0,0 +1,84 @@ +import json +from pathlib import Path + +import pandas as pd +from fastapi import APIRouter, HTTPException + +router = APIRouter(prefix="/api", tags=["benefits"]) + +_DATA_DIR = Path(__file__).parent / "data" + +with (_DATA_DIR / "member_insights.json").open() as f: + _MEMBER_INSIGHTS = json.load(f) + +_SCHEDULE_TEXT = (_DATA_DIR / "schedule_response.txt").read_text(encoding="utf-8").strip() + +_HISTORICAL = pd.read_csv(_DATA_DIR / "historical_procedures.csv") +_AVAILABLE = pd.read_csv(_DATA_DIR / "available_procedures.csv") + + +_OPS = { + "equals": lambda s, v: s == v, + "ne": lambda s, v: s != v, + "contains": lambda s, v: s.astype(str).str.contains(str(v), case=False, na=False), + "gt": lambda s, v: s > v, + "lt": lambda s, v: s < v, + "ge": lambda s, v: s >= v, + "le": lambda s, v: s <= v, +} + + +def _apply_filters_and_group(df: pd.DataFrame, filters_raw, group_by_raw) -> list[dict]: + try: + filters = json.loads(filters_raw) if isinstance(filters_raw, str) else (filters_raw or []) + group_by = json.loads(group_by_raw) if isinstance(group_by_raw, str) else (group_by_raw or []) + except json.JSONDecodeError as exc: + raise HTTPException(status_code=400, detail=f"Invalid JSON in filters or group_by: {exc}") + + result = df.copy() + for f in filters: + col, op, val = f.get("column"), f.get("operator"), f.get("value") + if col not in result.columns: + raise HTTPException(status_code=400, detail=f"Unknown column: {col}") + if op not in _OPS: + raise HTTPException(status_code=400, detail=f"Unsupported operator: {op}") + result = result[_OPS[op](result[col], val)] + + if group_by: + missing = [c for c in group_by if c not in result.columns] + if missing: + raise HTTPException(status_code=400, detail=f"Unknown group_by columns: {missing}") + numeric_cols = result.select_dtypes(include="number").columns.tolist() + result = result.groupby(group_by)[numeric_cols].mean().reset_index() + + return result.to_dict(orient="records") + + +@router.get("/member-insights") +def member_insights(): + return _MEMBER_INSIGHTS + + +@router.get("/schedule") +def schedule(): + return {"result": _SCHEDULE_TEXT} + + +@router.post("/historical-procedures") +def historical_procedures(payload: dict): + rows = _apply_filters_and_group( + _HISTORICAL, + payload.get("filters", "[]"), + payload.get("group_by", "[]"), + ) + return {"result": rows} + + +@router.post("/available-procedures") +def available_procedures(payload: dict): + rows = _apply_filters_and_group( + _AVAILABLE, + payload.get("filters", "[]"), + payload.get("group_by", "[]"), + ) + return {"result": rows} diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..9ddb313 --- /dev/null +++ b/app/config.py @@ -0,0 +1,21 @@ +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + secret_key: str + admin_user: str + admin_pass: str + token_expiry_hours: int = 24 + base_url: str = "https://taller-wox.fitlabs.dev" + db_path: str = "./leads.db" + material_dir: str = "./material" + reports_output_dir: str = "./app/data/reports_output" + + model_config = SettingsConfigDict(env_file=".env", case_sensitive=False, extra="ignore") + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/app/data/aetna_claim_review_summary.csv b/app/data/aetna_claim_review_summary.csv new file mode 100644 index 0000000..cf9d04e --- /dev/null +++ b/app/data/aetna_claim_review_summary.csv @@ -0,0 +1,11 @@ +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 +2024-01-18,99395,Preventive visit adult,310.00,250.00,250.00,0.00 +2023-12-05,90686,Influenza vaccine,55.00,40.00,40.00,0.00 +2023-11-12,87880,Strep A direct test,42.00,28.00,22.40,5.60 +2023-10-08,71046,Chest X-ray 2 views,210.00,140.00,112.00,28.00 +2023-09-22,93000,Electrocardiogram complete,138.00,90.00,72.00,18.00 +2023-08-14,99214,Office visit established mod complexity,250.00,170.00,136.00,34.00 +2023-07-03,36415,Venipuncture,18.00,12.00,9.60,2.40 diff --git a/app/data/aetna_email.txt b/app/data/aetna_email.txt new file mode 100644 index 0000000..c88b636 --- /dev/null +++ b/app/data/aetna_email.txt @@ -0,0 +1,8 @@ +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 +EOB available at member portal. diff --git a/app/data/available_procedures.csv b/app/data/available_procedures.csv new file mode 100644 index 0000000..159516d --- /dev/null +++ b/app/data/available_procedures.csv @@ -0,0 +1,29 @@ +procedure,location,facility_rating,distance_miles,gold_ppo_plan_accepted,silver_epo_plan_accepted,accepted_plans,cost_facility,cost_physician,cost_anesthesia,cost_medication,total_cost +MRI,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",1450.00,820.00,0.00,12.00,2282.00 +MRI,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",1380.00,790.00,0.00,11.00,2181.00 +MRI,Green Valley Clinic,4.5,8.4,true,false,"Gold PPO, Bronze HDHP",1520.00,860.00,0.00,12.50,2392.50 +MRI,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",1610.00,880.00,0.00,13.00,2503.00 +CT Scan,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",1600.00,750.00,0.00,8.00,2358.00 +CT Scan,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",1540.00,720.00,0.00,7.50,2267.50 +CT Scan,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",1750.00,820.00,0.00,9.00,2579.00 +X Ray,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",110.00,140.00,0.00,2.00,252.00 +X Ray,Green Valley Clinic,4.5,8.4,true,true,"Gold PPO, Family Plan - Silver EPO, Bronze HDHP",95.00,125.00,0.00,1.50,221.50 +X Ray,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",90.00,120.00,0.00,1.50,211.50 +X Ray,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",100.00,130.00,0.00,1.50,231.50 +Annual Physical Exam,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",80.00,180.00,0.00,5.00,265.00 +Annual Physical Exam,Green Valley Clinic,4.5,8.4,true,true,"Gold PPO, Family Plan - Silver EPO, Bronze HDHP",75.00,170.00,0.00,4.50,249.50 +Annual Physical Exam,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",85.00,200.00,0.00,4.50,289.50 +Annual Physical Exam,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",90.00,210.00,0.00,5.00,305.00 +Appendectomy,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",9100.00,5800.00,2200.00,195.00,17295.00 +Appendectomy,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",8200.00,5400.00,2100.00,180.00,15880.00 +Dental Cleaning,Green Valley Clinic,4.5,8.4,true,true,"Gold PPO, Family Plan - Silver EPO, Bronze HDHP",58.00,118.00,0.00,0.00,176.00 +Dental Cleaning,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO",60.00,120.00,0.00,0.00,180.00 +Dental Cleaning,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",55.00,115.00,0.00,0.00,170.00 +Vision Exam,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",35.00,105.00,0.00,0.00,140.00 +Vision Exam,Green Valley Clinic,4.5,8.4,true,true,"Gold PPO, Family Plan - Silver EPO, Bronze HDHP",38.00,108.00,0.00,0.00,146.00 +Vision Exam,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO",40.00,110.00,0.00,0.00,150.00 +Blood Test,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",25.00,55.00,0.00,2.50,82.50 +Blood Test,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",28.00,55.00,0.00,3.00,86.00 +Angioplasty,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",9432.80,4774.57,1894.37,834.80,16936.54 +Angioplasty,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",8920.00,4520.00,1850.00,810.00,16100.00 +Ultrasound,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",380.00,290.00,0.00,3.00,673.00 diff --git a/app/data/combined_email.txt b/app/data/combined_email.txt new file mode 100644 index 0000000..730a8d8 --- /dev/null +++ b/app/data/combined_email.txt @@ -0,0 +1,20 @@ +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. + +Best, +Dr. Martinez + +--- + +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? + +Charlie diff --git a/app/data/historical_procedures.csv b/app/data/historical_procedures.csv new file mode 100644 index 0000000..25930d5 --- /dev/null +++ b/app/data/historical_procedures.csv @@ -0,0 +1,51 @@ +member_name,relationship,age,gender,procedure,procedure_type,location,date,in_network,member_plan,accepted_plans,cost_facility,cost_physician,cost_anesthesia,cost_medication,total_cost,facility_rating,notes +Charlie Smith,Self,13,Male,Annual Physical Exam,preventive,City Hospital,2024-05-15,true,Gold PPO,"Gold PPO, Medicare Advantage",80.00,180.00,0.00,5.00,265.00,4.7,"Annual Physical Exam performed at City Hospital." +Charlie Smith,Self,13,Male,Dental Cleaning,preventive,Green Valley Clinic,2025-01-10,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",60.00,120.00,0.00,0.00,180.00,4.5,"Dental Cleaning performed at Green Valley Clinic." +Charlie Smith,Self,13,Male,Vision Exam,preventive,Sunrise Health,2023-08-22,true,Gold PPO,"Gold PPO, Bronze HDHP",40.00,110.00,0.00,0.00,150.00,4.3,"Vision Exam performed at Sunrise Health." +Charlie Smith,Self,13,Male,Blood Test,diagnostic,City Hospital,2024-11-04,true,Gold PPO,"Gold PPO, Medicare Advantage",25.00,55.00,0.00,2.50,82.50,4.7,"Cholesterol Panel performed at City Hospital." +Alice Smith,Spouse,42,Female,Annual Physical Exam,preventive,Regional Medical Center,2024-09-18,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",95.00,210.00,0.00,4.50,309.50,4.6,"Annual Physical Exam performed at Regional Medical Center." +Alice Smith,Spouse,42,Female,MRI,diagnostic,City Hospital,2023-11-02,true,Gold PPO,"Gold PPO, Medicare Advantage",1450.00,820.00,0.00,12.00,2282.00,4.7,"Lumbar MRI performed at City Hospital." +Alice Smith,Spouse,42,Female,Dental Cleaning,preventive,Green Valley Clinic,2024-08-12,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",65.00,125.00,0.00,0.00,190.00,4.5,"Dental Cleaning performed at Green Valley Clinic." +Bob Johnson,Self,55,Male,Appendectomy,surgery,Regional Medical Center,2024-02-04,true,Family Plan - Silver EPO,"Family Plan - Silver EPO, Gold PPO",8200.00,5400.00,2100.00,180.00,15880.00,4.6,"Laparoscopic appendectomy performed at Regional Medical Center." +Bob Johnson,Self,55,Male,Annual Physical Exam,preventive,Regional Medical Center,2024-10-22,false,Family Plan - Silver EPO,"Gold PPO",120.00,260.00,0.00,6.00,386.00,4.6,"Annual Physical Exam performed at Regional Medical Center." +Bob Johnson,Self,55,Male,CT Scan,diagnostic,City Hospital,2024-02-03,true,Family Plan - Silver EPO,"Gold PPO, Medicare Advantage",1600.00,750.00,0.00,8.00,2358.00,4.7,"Pre-op CT abdomen/pelvis at City Hospital." +Diana Roberts,Mother,67,Female,Annual Physical Exam,preventive,Sunrise Health,2024-07-09,true,Gold PPO,"Gold PPO, Bronze HDHP",95.00,220.00,0.00,5.00,320.00,4.3,"Annual Physical Exam performed at Sunrise Health." +Diana Roberts,Mother,67,Female,Vision Exam,preventive,Sunrise Health,2024-10-14,true,Gold PPO,"Gold PPO, Bronze HDHP",40.00,115.00,0.00,0.00,155.00,4.3,"Vision Exam performed at Sunrise Health." +Diana Roberts,Mother,67,Female,Blood Test,diagnostic,City Hospital,2025-02-18,true,Gold PPO,"Gold PPO, Medicare Advantage",28.00,60.00,0.00,3.00,91.00,4.7,"Cholesterol Panel performed at City Hospital." +Diana Roberts,Mother,67,Female,MRI,diagnostic,Regional Medical Center,2023-06-30,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",1520.00,840.00,0.00,10.00,2370.00,4.6,"Knee MRI performed at Regional Medical Center." +Ethan Smith,Son,11,Male,Annual Physical Exam,preventive,City Hospital,2025-04-02,true,Gold PPO,"Gold PPO, Medicare Advantage",70.00,170.00,0.00,4.00,244.00,4.7,"Annual Physical Exam performed at City Hospital." +Ethan Smith,Son,11,Male,Dental Cleaning,preventive,Green Valley Clinic,2025-03-15,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",55.00,115.00,0.00,0.00,170.00,4.5,"Dental Cleaning performed at Green Valley Clinic." +Ethan Smith,Son,11,Male,Vision Exam,preventive,Sunrise Health,2024-12-08,true,Gold PPO,"Gold PPO, Bronze HDHP",35.00,105.00,0.00,0.00,140.00,4.3,"Vision Exam performed at Sunrise Health." +Ethan Smith,Son,11,Male,X Ray,diagnostic,City Hospital,2024-09-21,true,Gold PPO,"Gold PPO, Medicare Advantage",105.00,135.00,0.00,2.00,242.00,4.7,"Wrist X Ray performed at City Hospital." +Alice Smith,Spouse,42,Female,Blood Test,diagnostic,City Hospital,2024-04-19,true,Gold PPO,"Gold PPO, Medicare Advantage",30.00,58.00,0.00,3.50,91.50,4.7,"Lipid panel at City Hospital." +Alice Smith,Spouse,42,Female,Vision Exam,preventive,Sunrise Health,2024-06-11,true,Gold PPO,"Gold PPO, Bronze HDHP",38.00,108.00,0.00,0.00,146.00,4.3,"Vision Exam performed at Sunrise Health." +Bob Johnson,Self,55,Male,Dental Cleaning,preventive,Green Valley Clinic,2024-12-03,true,Family Plan - Silver EPO,"Family Plan - Silver EPO, Gold PPO",60.00,120.00,0.00,0.00,180.00,4.5,"Dental Cleaning performed at Green Valley Clinic." +Bob Johnson,Self,55,Male,Blood Test,diagnostic,Regional Medical Center,2025-01-22,true,Family Plan - Silver EPO,"Family Plan - Silver EPO, Gold PPO",28.00,55.00,0.00,3.00,86.00,4.6,"A1C panel performed at Regional Medical Center." +Charlie Smith,Self,13,Male,X Ray,diagnostic,City Hospital,2025-03-28,true,Gold PPO,"Gold PPO, Medicare Advantage",110.00,140.00,0.00,2.00,252.00,4.7,"Ankle X Ray performed at City Hospital." +Frank Lee,Father,72,Male,CT Scan,diagnostic,Regional Medical Center,2024-08-29,false,Bronze HDHP,"Gold PPO, Medicare Advantage",1750.00,820.00,0.00,9.00,2579.00,4.6,"Head CT performed at Regional Medical Center." +Frank Lee,Father,72,Male,Annual Physical Exam,preventive,Sunrise Health,2024-11-19,true,Bronze HDHP,"Gold PPO, Bronze HDHP",95.00,225.00,0.00,5.50,325.50,4.3,"Annual Physical Exam performed at Sunrise Health." +Frank Lee,Father,72,Male,Vision Exam,preventive,Sunrise Health,2024-09-02,true,Bronze HDHP,"Gold PPO, Bronze HDHP",42.00,118.00,0.00,0.00,160.00,4.3,"Vision Exam performed at Sunrise Health." +Frank Lee,Father,72,Male,Blood Test,diagnostic,City Hospital,2025-02-25,true,Bronze HDHP,"Gold PPO, Medicare Advantage",30.00,58.00,0.00,4.00,92.00,4.7,"Comprehensive metabolic panel at City Hospital." +Grace Wong,Daughter,16,Female,Annual Physical Exam,preventive,Green Valley Clinic,2025-02-11,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",75.00,175.00,0.00,4.00,254.00,4.5,"Annual Physical Exam performed at Green Valley Clinic." +Grace Wong,Daughter,16,Female,Dental Cleaning,preventive,Green Valley Clinic,2024-11-30,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",58.00,118.00,0.00,0.00,176.00,4.5,"Dental Cleaning performed at Green Valley Clinic." +Grace Wong,Daughter,16,Female,Vision Exam,preventive,Sunrise Health,2025-01-26,true,Gold PPO,"Gold PPO, Bronze HDHP",36.00,106.00,0.00,0.00,142.00,4.3,"Vision Exam performed at Sunrise Health." +Henry Park,Self,38,Male,MRI,diagnostic,City Hospital,2024-07-15,true,Family Plan - Silver EPO,"Gold PPO, Family Plan - Silver EPO",1500.00,830.00,0.00,11.00,2341.00,4.7,"Shoulder MRI performed at City Hospital." +Henry Park,Self,38,Male,Annual Physical Exam,preventive,Regional Medical Center,2024-08-08,true,Family Plan - Silver EPO,"Gold PPO, Family Plan - Silver EPO",90.00,210.00,0.00,5.00,305.00,4.6,"Annual Physical Exam performed at Regional Medical Center." +Henry Park,Self,38,Male,X Ray,diagnostic,City Hospital,2024-07-10,true,Family Plan - Silver EPO,"Gold PPO, Medicare Advantage",120.00,145.00,0.00,2.50,267.50,4.7,"Shoulder X Ray performed at City Hospital." +Henry Park,Self,38,Male,Dental Cleaning,preventive,Green Valley Clinic,2024-10-04,true,Family Plan - Silver EPO,"Gold PPO, Family Plan - Silver EPO",62.00,122.00,0.00,0.00,184.00,4.5,"Dental Cleaning performed at Green Valley Clinic." +Isabel Cruz,Mother,69,Female,Appendectomy,surgery,City Hospital,2024-03-12,true,Gold PPO,"Gold PPO, Medicare Advantage",9100.00,5800.00,2200.00,195.00,17295.00,4.7,"Open appendectomy performed at City Hospital." +Isabel Cruz,Mother,69,Female,CT Scan,diagnostic,City Hospital,2024-03-11,true,Gold PPO,"Gold PPO, Medicare Advantage",1620.00,780.00,0.00,8.50,2408.50,4.7,"Pre-op CT abdomen/pelvis at City Hospital." +Isabel Cruz,Mother,69,Female,Annual Physical Exam,preventive,Sunrise Health,2024-06-22,true,Gold PPO,"Gold PPO, Bronze HDHP",95.00,225.00,0.00,5.50,325.50,4.3,"Annual Physical Exam performed at Sunrise Health." +Isabel Cruz,Mother,69,Female,Blood Test,diagnostic,City Hospital,2025-01-30,true,Gold PPO,"Gold PPO, Medicare Advantage",32.00,62.00,0.00,3.50,97.50,4.7,"Comprehensive metabolic panel at City Hospital." +Jack Miller,Self,29,Male,X Ray,diagnostic,Sunrise Health,2024-05-04,true,Bronze HDHP,"Gold PPO, Bronze HDHP",95.00,130.00,0.00,2.00,227.00,4.3,"Knee X Ray performed at Sunrise Health." +Jack Miller,Self,29,Male,Annual Physical Exam,preventive,Sunrise Health,2025-02-06,true,Bronze HDHP,"Gold PPO, Bronze HDHP",85.00,200.00,0.00,4.50,289.50,4.3,"Annual Physical Exam performed at Sunrise Health." +Jack Miller,Self,29,Male,Dental Cleaning,preventive,Green Valley Clinic,2024-10-17,true,Bronze HDHP,"Family Plan - Silver EPO",58.00,118.00,0.00,0.00,176.00,4.5,"Dental Cleaning performed at Green Valley Clinic." +Karen Davis,Spouse,46,Female,MRI,diagnostic,Regional Medical Center,2024-04-09,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",1480.00,810.00,0.00,11.00,2301.00,4.6,"Brain MRI performed at Regional Medical Center." +Karen Davis,Spouse,46,Female,Annual Physical Exam,preventive,Regional Medical Center,2024-11-25,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",100.00,225.00,0.00,5.50,330.50,4.6,"Annual Physical Exam performed at Regional Medical Center." +Karen Davis,Spouse,46,Female,Dental Cleaning,preventive,Green Valley Clinic,2024-09-30,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",60.00,120.00,0.00,0.00,180.00,4.5,"Dental Cleaning performed at Green Valley Clinic." +Liam OBrien,Son,9,Male,Annual Physical Exam,preventive,City Hospital,2025-03-05,true,Gold PPO,"Gold PPO, Medicare Advantage",70.00,170.00,0.00,4.00,244.00,4.7,"Annual Physical Exam performed at City Hospital." +Liam OBrien,Son,9,Male,Vision Exam,preventive,Sunrise Health,2024-12-19,true,Gold PPO,"Gold PPO, Bronze HDHP",35.00,105.00,0.00,0.00,140.00,4.3,"Vision Exam performed at Sunrise Health." +Liam OBrien,Son,9,Male,Dental Cleaning,preventive,Green Valley Clinic,2025-04-11,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",55.00,115.00,0.00,0.00,170.00,4.5,"Dental Cleaning performed at Green Valley Clinic." +Maria Lopez,Daughter,19,Female,Annual Physical Exam,preventive,Green Valley Clinic,2025-01-08,true,Family Plan - Silver EPO,"Gold PPO, Family Plan - Silver EPO",80.00,185.00,0.00,4.50,269.50,4.5,"Annual Physical Exam performed at Green Valley Clinic." +Maria Lopez,Daughter,19,Female,Blood Test,diagnostic,City Hospital,2025-02-14,true,Family Plan - Silver EPO,"Gold PPO, Medicare Advantage",28.00,55.00,0.00,3.00,86.00,4.7,"Iron panel performed at City Hospital." +Maria Lopez,Daughter,19,Female,Vision Exam,preventive,Sunrise Health,2024-08-20,true,Family Plan - Silver EPO,"Gold PPO, Bronze HDHP",38.00,108.00,0.00,0.00,146.00,4.3,"Vision Exam performed at Sunrise Health." diff --git a/app/data/member_insights.json b/app/data/member_insights.json new file mode 100644 index 0000000..825a887 --- /dev/null +++ b/app/data/member_insights.json @@ -0,0 +1,76 @@ +{ + "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-2025.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": "2024-05-15", + "recommended_frequency_months": 12, + "due_since_months": 12, + "priority": "high" + }, + { + "procedure": "Dental Cleaning", + "last_date": "2025-01-10", + "recommended_frequency_months": 6, + "due_since_months": 10, + "priority": "medium" + }, + { + "procedure": "Vision Exam", + "last_date": "2023-08-22", + "recommended_frequency_months": 24, + "due_since_months": 8, + "priority": "medium" + }, + { + "procedure": "Blood Test - Cholesterol Panel", + "last_date": "2024-11-04", + "recommended_frequency_months": 12, + "due_since_months": 6, + "priority": "low" + } + ] + } +} diff --git a/app/data/provider_email.txt b/app/data/provider_email.txt new file mode 100644 index 0000000..d3a9a50 --- /dev/null +++ b/app/data/provider_email.txt @@ -0,0 +1,5 @@ +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. +Patient stable, NPO since midnight. Will reassess in 2h. diff --git a/app/data/reports_output/.gitkeep b/app/data/reports_output/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/data/schedule_response.txt b/app/data/schedule_response.txt new file mode 100644 index 0000000..921865e --- /dev/null +++ b/app/data/schedule_response.txt @@ -0,0 +1 @@ +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. diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..74e74cc --- /dev/null +++ b/app/db.py @@ -0,0 +1,128 @@ +import sqlite3 +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator + +from app.config import get_settings + +SCHEMA = """ +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); +""" + + +@contextmanager +def _conn() -> Iterator[sqlite3.Connection]: + settings = get_settings() + db_path = Path(settings.db_path) + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + try: + yield conn + conn.commit() + finally: + conn.close() + + +def init_db() -> None: + with _conn() as conn: + conn.executescript(SCHEMA) + + +def upsert_lead( + nombre: str, + email: str, + empresa: str, + ip: str | None, + user_agent: str | None, + consent: bool, +) -> int: + with _conn() as conn: + cur = conn.execute( + """ + INSERT INTO leads (nombre, email, empresa, ip, user_agent, consent) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(email) DO UPDATE SET + nombre = excluded.nombre, + empresa = excluded.empresa, + ip = excluded.ip, + user_agent = excluded.user_agent, + consent = excluded.consent, + times_registered = times_registered + 1, + last_seen = CURRENT_TIMESTAMP + RETURNING id + """, + (nombre, email, empresa, ip, user_agent, 1 if consent else 0), + ) + return cur.fetchone()["id"] + + +def get_lead_by_email(email: str) -> dict | None: + with _conn() as conn: + row = conn.execute( + "SELECT * FROM leads WHERE email = ?", (email,) + ).fetchone() + return dict(row) if row else None + + +def log_download(lead_email: str, filename: str, ip: str | None) -> None: + with _conn() as conn: + conn.execute( + "INSERT INTO downloads (lead_email, filename, ip) VALUES (?, ?, ?)", + (lead_email, filename, ip), + ) + + +def list_leads(limit: int = 100, offset: int = 0) -> list[dict]: + with _conn() as conn: + rows = conn.execute( + "SELECT * FROM leads ORDER BY id ASC LIMIT ? OFFSET ?", + (limit, offset), + ).fetchall() + return [dict(r) for r in rows] + + +def stats() -> dict: + with _conn() as conn: + total_leads = conn.execute("SELECT COUNT(*) AS c FROM leads").fetchone()["c"] + total_downloads = conn.execute("SELECT COUNT(*) AS c FROM downloads").fetchone()["c"] + per_file = { + r["filename"]: r["c"] + for r in conn.execute( + "SELECT filename, COUNT(*) AS c FROM downloads GROUP BY filename" + ).fetchall() + } + top_empresas = [ + {"empresa": r["empresa"], "count": r["c"]} + for r in conn.execute( + "SELECT empresa, COUNT(*) AS c FROM leads GROUP BY empresa ORDER BY c DESC LIMIT 5" + ).fetchall() + ] + return { + "total_leads": total_leads, + "total_downloads": total_downloads, + "downloads_por_archivo": per_file, + "top_5_empresas": top_empresas, + } diff --git a/app/frontend.py b/app/frontend.py new file mode 100644 index 0000000..0c1d025 --- /dev/null +++ b/app/frontend.py @@ -0,0 +1,136 @@ +from pathlib import Path + +from fastapi import APIRouter, Form, HTTPException, Request +from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from itsdangerous import BadSignature, SignatureExpired +from pydantic import EmailStr + +from app.config import get_settings +from app.db import log_download, upsert_lead +from app.security import ( + create_download_token, + is_honeypot_filled, + verify_download_token, +) + +router = APIRouter() + +_TEMPLATES_DIR = Path(__file__).parent / "templates" +templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) + +DOWNLOADS = [ + { + "filename": "taller-wox-tecnico.zip", + "icon": "🧩", + "title": "Material técnico", + "description": "Specs OpenAPI, configs y artefactos para importar a watsonx Orchestrate.", + }, + { + "filename": "taller-wox-funcional.zip", + "icon": "📚", + "title": "Material funcional", + "description": "Manual paso a paso del bootcamp y deck de slides.", + }, +] +ALLOWED_FILENAMES = {d["filename"] for d in DOWNLOADS} + + +@router.get("/", response_class=HTMLResponse) +def landing(request: Request, error: str | None = None): + error_msg = None + if error == "token-invalido": + error_msg = "El link expiró o es inválido. Regístrate de nuevo para descargar el material." + return templates.TemplateResponse( + "index.html", + {"request": request, "error": error_msg}, + ) + + +@router.post("/register") +def register( + request: Request, + nombre: str = Form(..., min_length=2, max_length=80), + email: EmailStr = Form(...), + empresa: str = Form(..., min_length=2, max_length=100), + consentimiento: str = Form(...), + website: str | None = Form(default=""), +): + if consentimiento != "on": + raise HTTPException(status_code=400, detail="Consentimiento requerido") + + if is_honeypot_filled(website): + fake_token = create_download_token(email="honeypot@discarded.local", nombre="x") + return RedirectResponse(url=f"/descargas?token={fake_token}", status_code=303) + + client_ip = request.client.host if request.client else None + user_agent = request.headers.get("user-agent") + + upsert_lead( + nombre=nombre, + email=str(email), + empresa=empresa, + ip=client_ip, + user_agent=user_agent, + consent=True, + ) + + token = create_download_token(email=str(email), nombre=nombre) + return RedirectResponse(url=f"/descargas?token={token}", status_code=303) + + +@router.get("/descargas", response_class=HTMLResponse) +def descargas(request: Request, token: str | None = None): + if not token: + return RedirectResponse(url="/?error=token-invalido", status_code=307) + try: + data = verify_download_token(token) + except (SignatureExpired, BadSignature): + return RedirectResponse(url="/?error=token-invalido", status_code=307) + + settings = get_settings() + material_dir = Path(settings.material_dir) + downloads_view = [] + for d in DOWNLOADS: + path = material_dir / d["filename"] + downloads_view.append({ + **d, + "available": path.exists(), + "size_mb": round(path.stat().st_size / (1024 * 1024), 1) if path.exists() else 0, + }) + + return templates.TemplateResponse( + "descargas.html", + { + "request": request, + "nombre": data.get("nombre", "amig@"), + "token": token, + "downloads": downloads_view, + }, + ) + + +@router.get("/download/{filename}") +def download(request: Request, filename: str, token: str | None = None): + if not token: + raise HTTPException(status_code=401, detail="Missing token") + try: + data = verify_download_token(token) + except (SignatureExpired, BadSignature): + raise HTTPException(status_code=401, detail="Invalid or expired token") + + if filename not in ALLOWED_FILENAMES: + raise HTTPException(status_code=404, detail="File not found") + + path = Path(get_settings().material_dir) / filename + if not path.exists(): + raise HTTPException(status_code=404, detail="File not available yet") + + client_ip = request.client.host if request.client else None + log_download(lead_email=data["email"], filename=filename, ip=client_ip) + + return FileResponse( + path=str(path), + filename=filename, + media_type="application/zip", + ) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..f921fe1 --- /dev/null +++ b/app/main.py @@ -0,0 +1,47 @@ +from pathlib import Path + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + +from app import admin, benefits_api, frontend, reports_api +from app.config import get_settings +from app.db import init_db + +settings = get_settings() + +app = FastAPI(title="taller-wox.fitlabs.dev", version="1.0.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=False, + allow_methods=["GET", "POST"], + allow_headers=["*"], +) + +_STATIC_DIR = Path(__file__).parent.parent / "static" +app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") + +app.include_router(frontend.router) +app.include_router(benefits_api.router) +app.include_router(reports_api.router) +app.include_router(admin.router) + +_reports_dir = Path(settings.reports_output_dir) +_reports_dir.mkdir(parents=True, exist_ok=True) +app.mount( + "/api/reports/output", + StaticFiles(directory=str(_reports_dir)), + name="reports_output", +) + + +@app.on_event("startup") +def on_startup() -> None: + init_db() + + +@app.get("/health") +def health(): + return {"status": "ok", "base_url": settings.base_url} diff --git a/app/reports_api.py b/app/reports_api.py new file mode 100644 index 0000000..d276212 --- /dev/null +++ b/app/reports_api.py @@ -0,0 +1,129 @@ +import json +import uuid +from pathlib import Path + +import pandas as pd +import plotly.express as px +import plotly.io as pio +from fastapi import APIRouter, HTTPException +from jinja2 import Environment, FileSystemLoader, select_autoescape + +from app.config import get_settings + +router = APIRouter(prefix="/api/reports", tags=["reports"]) + +_DATA_DIR = Path(__file__).parent / "data" +_TEMPLATES_DIR = Path(__file__).parent / "templates" + +_env = Environment( + loader=FileSystemLoader(str(_TEMPLATES_DIR)), + autoescape=select_autoescape(["html"]), +) + + +CARE_REPORT_PRESET = [ + {"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", + }, + }, +] + + +def _resolve_path(relative: str) -> Path: + cleaned = relative.lstrip("./") + if cleaned.startswith("data/"): + cleaned = cleaned[len("data/"):] + return _DATA_DIR / cleaned + + +def _render_header(params: dict) -> str: + title = params.get("title", "") + return f"

{title}

" + + +def _render_overview(params: dict) -> str: + title = params.get("title", "Overview") + text_file = _resolve_path(params.get("text_file", "")) + if not text_file.exists(): + raise HTTPException(status_code=400, detail=f"text_file not found: {params.get('text_file')}") + text = text_file.read_text(encoding="utf-8") + paragraphs = "".join(f"

{line}

" for line in text.splitlines() if line.strip()) + return f"

{title}

{paragraphs}
" + + +def _render_claim_chart() -> str: + df = pd.read_csv(_DATA_DIR / "aetna_claim_review_summary.csv") + fig = px.bar( + df, + x="CPT_Code", + y=["Charged_Amount", "Allowed_Amount", "Patient_Responsibility"], + barmode="group", + title="Claim Review by CPT Code", + color_discrete_sequence=["#0A1F44", "#1E4FA8", "#FF7A00"], + ) + return pio.to_html(fig, full_html=False, include_plotlyjs="cdn") + + +def _render_table(params: dict) -> str: + title = params.get("title", "") + csv_file = _resolve_path(params.get("csv_file", "")) + if not csv_file.exists(): + raise HTTPException(status_code=400, detail=f"csv_file not found: {params.get('csv_file')}") + df = pd.read_csv(csv_file) + return f"

{title}

" + df.to_html(index=False, classes="report-table", border=0) + + +_RENDERERS = { + "header": lambda params: _render_header(params), + "overview": lambda params: _render_overview(params), + "claim_review_chart": lambda _params: _render_claim_chart(), + "table": lambda params: _render_table(params), +} + + +@router.post("/generate-report") +def generate_report(payload: dict): + try: + layout = json.loads(payload["layout_config"]) + except (KeyError, json.JSONDecodeError) as exc: + raise HTTPException(status_code=400, detail=f"Invalid layout_config: {exc}") + + expanded: list[dict] = [] + for item in layout: + if item == "care_report": + expanded.extend(CARE_REPORT_PRESET) + elif isinstance(item, dict): + expanded.append(item) + else: + raise HTTPException(status_code=400, detail=f"Unknown layout item: {item!r}") + + parts: list[str] = [] + for el in expanded: + kind = el.get("element_type") + if kind not in _RENDERERS: + raise HTTPException(status_code=400, detail=f"Unknown element_type: {kind}") + parts.append(_RENDERERS[kind](el.get("parameters", {}))) + + template = _env.get_template("report.html") + html = template.render(content="\n".join(parts)) + + settings = get_settings() + output_dir = Path(settings.reports_output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + report_id = f"{uuid.uuid4().hex[:12]}.html" + (output_dir / report_id).write_text(html, encoding="utf-8") + + return {"public_url": f"{settings.base_url}/api/reports/output/{report_id}"} diff --git a/app/security.py b/app/security.py new file mode 100644 index 0000000..6bfa19c --- /dev/null +++ b/app/security.py @@ -0,0 +1,41 @@ +import secrets + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from itsdangerous import URLSafeTimedSerializer + +from app.config import get_settings + + +_basic = HTTPBasic() + + +def _serializer() -> URLSafeTimedSerializer: + return URLSafeTimedSerializer(get_settings().secret_key, salt="download") + + +def create_download_token(email: str, nombre: str) -> str: + return _serializer().dumps({"email": email, "nombre": nombre}) + + +def verify_download_token(token: str) -> dict: + settings = get_settings() + max_age_seconds = max(1, settings.token_expiry_hours * 3600) + return _serializer().loads(token, max_age=max_age_seconds) + + +def is_honeypot_filled(value: str | None) -> bool: + return bool(value) + + +def require_admin(credentials: HTTPBasicCredentials = Depends(_basic)) -> str: + settings = get_settings() + user_ok = secrets.compare_digest(credentials.username, settings.admin_user) + pass_ok = secrets.compare_digest(credentials.password, settings.admin_pass) + if not (user_ok and pass_ok): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials", + headers={"WWW-Authenticate": "Basic"}, + ) + return credentials.username diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..14b704e --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,36 @@ + + + + + + {% block title %}Bootcamp Agentic AI — watsonx Orchestrate | FactorIT{% endblock %} + + + + + + + + + + + + + + + + {% block content %}{% endblock %} + +
+ +
+ + + + diff --git a/app/templates/descargas.html b/app/templates/descargas.html new file mode 100644 index 0000000..dc5cbe9 --- /dev/null +++ b/app/templates/descargas.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}Material del bootcamp — {{ nombre }}{% endblock %} + +{% block content %} +
+

¡Hola {{ nombre }}!

+

Acá tienes todo el material del bootcamp.

+ +
+ {% for d in downloads %} +
+
{{ d.icon }}
+

{{ d.title }}

+

{{ d.description }}

+

{% if d.available %}{{ d.size_mb }} MB{% else %}Próximamente{% endif %}

+ {% if d.available %} + Descargar + {% else %} + No disponible + {% endif %} +
+ {% endfor %} +
+
+{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..8ea12fc --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} + +{% block content %} +{% if error %} + +{% endif %} + +
+
+

FACTORIT · FIT

+

Bootcamp Agentic AI con watsonx Orchestrate

+

Construye tu primer agente de IA en 4 horas.

+ Acceder al material → +
+
+ +
+

¿Qué vas a construir?

+
+
⚙️

Tu primer agente

Conecta una API real a un agente conversacional sin escribir código.

+
📚

Multi-agente con RAG

Compón agentes especializados con base de conocimiento documental.

+
📊

Reportes y APIs

Genera reportes ejecutivos invocando endpoints en vivo.

+
+
+ +
+

El taller en números

+
+
4hDuración
+
6Módulos
+
0Líneas de código
+
100%Hands-on
+
+
+ +
+

Descarga todo el material

+

Registra tus datos para acceder al kit completo del bootcamp.

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+{% endblock %} diff --git a/app/templates/report.html b/app/templates/report.html new file mode 100644 index 0000000..ecf1d3d --- /dev/null +++ b/app/templates/report.html @@ -0,0 +1,21 @@ + + + + + Care Report + + + +

FACTORIT · FIT

+ {{ content | safe }} +
Generado por AskReporting · taller-wox.fitlabs.dev
+ + diff --git a/docs/superpowers/plans/2026-05-12-taller-wox.md b/docs/superpowers/plans/2026-05-12-taller-wox.md new file mode 100644 index 0000000..662ac9a --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-taller-wox.md @@ -0,0 +1,2910 @@ +# Portal taller-wox.fitlabs.dev — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a FastAPI service that serves a branded FactorIT landing page + lead-capture registration + gated download of 2 ZIPs, plus 5 REST endpoints consumed live by IBM watsonx Orchestrate agents during the workshop on 2026-05-14. + +**Architecture:** Single FastAPI service in one Docker container deployed on Coolify. SQLite for leads, file-based CSVs/JSON loaded in memory at boot, Jinja2 + Plotly for the report endpoint, `itsdangerous` for download tokens. HTTPS via Let's Encrypt (auto from Coolify). Three persistent volumes: `leads.db`, `app/data/reports_output/`, `material/`. + +**Tech Stack:** Python 3.11 · FastAPI · Uvicorn · SQLite · pandas · Jinja2 · Plotly · itsdangerous · pydantic-settings · pytest · Docker + +**Source of truth for design decisions:** `docs/superpowers/specs/2026-05-12-taller-wox-design.md`. When the original SPEC (`SPEC_taller_wox_fitlabs.md`) and this design disagree, the design wins. + +--- + +## File Structure + +Files this plan creates (locked decomposition): + +``` +app/ +├── __init__.py # empty, marks package +├── main.py # FastAPI app, CORS, static mount, router includes, startup +├── config.py # Settings via pydantic-settings, env vars +├── db.py # SQLite init + schema + helpers (upsert_lead, log_download, list_leads, stats) +├── security.py # token sign/verify (itsdangerous), basic auth dep, honeypot check +├── frontend.py # router: GET /, GET /descargas, POST /register, GET /download/{filename} +├── benefits_api.py # router: endpoints 1-4 (historical, available, member-insights, schedule) +├── reports_api.py # router: endpoint 5 (generate-report) + the StaticFiles mount for /api/reports/output +├── admin.py # router: /admin/leads.json, /admin/leads.csv, /admin/stats +├── data/ +│ ├── historical_procedures.csv # ~50 rows, synthetic +│ ├── available_procedures.csv # ~28 rows, synthetic +│ ├── member_insights.json # static, dates re-calibrated to 2026 +│ ├── schedule_response.txt # static text +│ ├── combined_email.txt # for endpoint 5 +│ ├── provider_email.txt # for endpoint 5 +│ ├── aetna_email.txt # for endpoint 5 +│ ├── aetna_claim_review_summary.csv # for endpoint 5, ~10 rows +│ └── reports_output/ # generated HTMLs land here (persistent volume in prod) +│ └── .gitkeep +└── templates/ + ├── base.html # shared layout: header, footer, fonts, palette + ├── index.html # landing + inline registration form + ├── descargas.html # post-registration with 2 download cards + └── report.html # wrapper for endpoint 5 generated reports + +static/ +├── css/styles.css # FIT palette, hero, cards, form, responsive +├── img/ +│ ├── LogoFIT.png # existing, compressed copy goes here +│ └── favicon.ico # placeholder generated from LogoFIT +└── js/app.js # honeypot bot detection (light), smooth scroll, form UX + +tests/ +├── __init__.py +├── conftest.py # pytest fixtures: TestClient, temp SQLite, env vars +├── test_db.py +├── test_security.py +├── test_frontend.py +├── test_benefits_api.py +├── test_reports_api.py +└── test_admin.py + +material/ # empty in repo (gitignored); Felipe uploads zips to volume in prod +└── .gitkeep + +Dockerfile +requirements.txt +.env.example +README.md +``` + +**Responsibility per file:** +- `config.py` owns env-var loading. No other module reads `os.environ` directly. +- `db.py` owns the SQLite connection lifecycle and schema. No SQL strings outside this file. +- `security.py` owns token serialization and the basic-auth dependency. No router does its own crypto. +- Each router file (`frontend.py`, `benefits_api.py`, `reports_api.py`, `admin.py`) only knows its own routes and depends on `db`, `security`, `config` as needed. +- `main.py` is the only place that wires routers, middleware, and static mounts. Touching `main.py` should be rare after Task 2. + +--- + +## Task ordering rationale + +Tasks 1-4 are foundation (project skeleton, config, DB, security). Tasks 5-9 are the workshop API (the critical path for Thursday — must work end-to-end first). Tasks 10-13 are the frontend (visible but lower risk; you can ship the API without it for last-minute use). Task 14 is admin (lowest priority). Task 15 is Dockerization + deploy notes. + +If time runs out, the order above is also the priority order to abandon from the bottom up. + +--- + +## Task 1: Project bootstrap + +**Files:** +- Create: `requirements.txt` +- Create: `.env.example` +- Create: `app/__init__.py` (empty) +- Create: `app/main.py` +- Create: `tests/__init__.py` (empty) +- Create: `tests/conftest.py` +- Create: `tests/test_smoke.py` + +- [ ] **Step 1: Write `requirements.txt`** + +```text +fastapi==0.115.0 +uvicorn[standard]==0.32.0 +pydantic==2.9.2 +pydantic-settings==2.6.1 +python-multipart==0.0.12 +itsdangerous==2.2.0 +jinja2==3.1.4 +pandas==2.2.3 +plotly==5.24.1 +pytest==8.3.3 +httpx==0.27.2 +``` + +`httpx` is needed for FastAPI's `TestClient`. `python-multipart` for form parsing. Pinned versions for reproducibility. + +- [ ] **Step 2: Write `.env.example`** + +```bash +# Secret for signing download tokens. Use `python -c "import secrets; print(secrets.token_urlsafe(48))"` to generate +SECRET_KEY=replace-me-with-a-long-random-string + +# HTTP Basic auth for /admin/* +ADMIN_USER=admin +ADMIN_PASS=replace-me + +# Download token expiration in hours +TOKEN_EXPIRY_HOURS=24 + +# Public base URL (used in report URLs and elsewhere) +BASE_URL=https://taller-wox.fitlabs.dev + +# Path where Felipe uploads the 2 zips (mounted volume in Coolify, ./material in dev) +MATERIAL_DIR=./material + +# Path where SQLite file lives (mounted file in Coolify, ./leads.db in dev) +DB_PATH=./leads.db + +# Where generated reports are written (mounted dir in Coolify) +REPORTS_OUTPUT_DIR=./app/data/reports_output +``` + +- [ ] **Step 3: Write `app/main.py` (minimal)** + +```python +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +app = FastAPI(title="taller-wox.fitlabs.dev", version="1.0.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=False, + allow_methods=["GET", "POST"], + allow_headers=["*"], +) + + +@app.get("/health") +def health(): + return {"status": "ok"} +``` + +- [ ] **Step 4: Write `tests/conftest.py`** + +```python +import os +import tempfile +from pathlib import Path + +import pytest + +# Set test env BEFORE importing the app +os.environ["SECRET_KEY"] = "test-secret-key-only-for-tests-not-secure" +os.environ["ADMIN_USER"] = "testadmin" +os.environ["ADMIN_PASS"] = "testpass" +os.environ["TOKEN_EXPIRY_HOURS"] = "24" +os.environ["BASE_URL"] = "http://testserver" + +_tmp = Path(tempfile.mkdtemp(prefix="taller-wox-test-")) +os.environ["DB_PATH"] = str(_tmp / "test_leads.db") +os.environ["MATERIAL_DIR"] = str(_tmp / "material") +os.environ["REPORTS_OUTPUT_DIR"] = str(_tmp / "reports_output") +(_tmp / "material").mkdir(parents=True, exist_ok=True) +(_tmp / "reports_output").mkdir(parents=True, exist_ok=True) + + +@pytest.fixture +def client(): + from fastapi.testclient import TestClient + from app.main import app + return TestClient(app) +``` + +- [ ] **Step 5: Write `tests/test_smoke.py`** + +```python +def test_health(client): + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} +``` + +- [ ] **Step 6: Install deps and run the test** + +```bash +python3.11 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +pytest tests/test_smoke.py -v +``` + +Expected: `1 passed`. + +- [ ] **Step 7: Verify dev server starts** + +```bash +uvicorn app.main:app --reload --port 8000 +``` + +In another terminal: +```bash +curl http://localhost:8000/health +``` + +Expected output: `{"status":"ok"}`. Stop the server with Ctrl+C. + +- [ ] **Step 8: Commit** + +```bash +git add requirements.txt .env.example app/ tests/ +git commit -m "feat(bootstrap): minimal FastAPI app with health endpoint and test harness" +``` + +--- + +## Task 2: Settings via pydantic-settings + +**Files:** +- Create: `app/config.py` +- Modify: `app/main.py` +- Create: `tests/test_config.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_config.py +from app.config import get_settings + + +def test_settings_loaded_from_env(): + settings = get_settings() + assert settings.secret_key == "test-secret-key-only-for-tests-not-secure" + assert settings.admin_user == "testadmin" + assert settings.admin_pass == "testpass" + assert settings.token_expiry_hours == 24 + assert settings.base_url == "http://testserver" + assert settings.db_path.endswith("test_leads.db") + assert settings.material_dir.endswith("material") + assert settings.reports_output_dir.endswith("reports_output") +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +pytest tests/test_config.py -v +``` + +Expected: `ModuleNotFoundError: No module named 'app.config'`. + +- [ ] **Step 3: Implement `app/config.py`** + +```python +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + secret_key: str + admin_user: str + admin_pass: str + token_expiry_hours: int = 24 + base_url: str = "https://taller-wox.fitlabs.dev" + db_path: str = "./leads.db" + material_dir: str = "./material" + reports_output_dir: str = "./app/data/reports_output" + + model_config = SettingsConfigDict(env_file=".env", case_sensitive=False) + + +@lru_cache +def get_settings() -> Settings: + return Settings() +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +pytest tests/test_config.py -v +``` + +Expected: `1 passed`. + +- [ ] **Step 5: Wire settings into `app/main.py`** + +Replace `app/main.py` with: + +```python +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.config import get_settings + +settings = get_settings() + +app = FastAPI(title="taller-wox.fitlabs.dev", version="1.0.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=False, + allow_methods=["GET", "POST"], + allow_headers=["*"], +) + + +@app.get("/health") +def health(): + return {"status": "ok", "base_url": settings.base_url} +``` + +- [ ] **Step 6: Re-run smoke test** + +```bash +pytest -v +``` + +Expected: `2 passed`. + +- [ ] **Step 7: Commit** + +```bash +git add app/config.py app/main.py tests/test_config.py +git commit -m "feat(config): typed settings via pydantic-settings" +``` + +--- + +## Task 3: SQLite layer (db.py) + +**Files:** +- Create: `app/db.py` +- Create: `tests/test_db.py` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/test_db.py +from datetime import datetime + +import pytest + +from app.db import ( + init_db, + upsert_lead, + get_lead_by_email, + log_download, + list_leads, + stats, +) + + +@pytest.fixture(autouse=True) +def fresh_db(tmp_path, monkeypatch): + """Each test gets its own DB file so they don't bleed into each other.""" + db_file = tmp_path / "test.db" + monkeypatch.setenv("DB_PATH", str(db_file)) + from app import config + config.get_settings.cache_clear() + init_db() + yield + config.get_settings.cache_clear() + + +def test_upsert_new_lead_creates_row(): + lead_id = upsert_lead( + nombre="Felipe", + email="felipe@factorit.com", + empresa="FactorIT", + ip="1.2.3.4", + user_agent="pytest", + consent=True, + ) + assert lead_id > 0 + lead = get_lead_by_email("felipe@factorit.com") + assert lead["nombre"] == "Felipe" + assert lead["empresa"] == "FactorIT" + assert lead["times_registered"] == 1 + + +def test_upsert_duplicate_email_increments_times_registered(): + upsert_lead( + nombre="Felipe", + email="felipe@factorit.com", + empresa="FactorIT", + ip="1.2.3.4", + user_agent="pytest", + consent=True, + ) + upsert_lead( + nombre="Felipe Arentsen", + email="felipe@factorit.com", + empresa="FactorIT Chile", + ip="5.6.7.8", + user_agent="pytest-2", + consent=True, + ) + lead = get_lead_by_email("felipe@factorit.com") + assert lead["times_registered"] == 2 + # latest data wins + assert lead["nombre"] == "Felipe Arentsen" + assert lead["empresa"] == "FactorIT Chile" + + +def test_log_download_records_event(): + log_download( + lead_email="felipe@factorit.com", + filename="taller-wox-tecnico.zip", + ip="1.2.3.4", + ) + s = stats() + assert s["total_downloads"] == 1 + assert s["downloads_por_archivo"]["taller-wox-tecnico.zip"] == 1 + + +def test_list_leads_paginates(): + for i in range(5): + upsert_lead( + nombre=f"User {i}", + email=f"user{i}@test.com", + empresa=f"Co {i}", + ip="1.2.3.4", + user_agent="pytest", + consent=True, + ) + page = list_leads(limit=2, offset=0) + assert len(page) == 2 + page2 = list_leads(limit=2, offset=2) + assert len(page2) == 2 + assert page[0]["email"] != page2[0]["email"] + + +def test_stats_top_empresas(): + for empresa in ["ACME", "ACME", "ACME", "Globex", "Globex", "Initech"]: + upsert_lead( + nombre="X", + email=f"{empresa}-{datetime.utcnow().timestamp()}@x.com", + empresa=empresa, + ip="1.2.3.4", + user_agent="pytest", + consent=True, + ) + s = stats() + assert s["total_leads"] == 6 + assert s["top_5_empresas"][0] == {"empresa": "ACME", "count": 3} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pytest tests/test_db.py -v +``` + +Expected: `ModuleNotFoundError` or `ImportError` on `app.db`. + +- [ ] **Step 3: Implement `app/db.py`** + +```python +import sqlite3 +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator + +from app.config import get_settings + +SCHEMA = """ +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); +""" + + +@contextmanager +def _conn() -> Iterator[sqlite3.Connection]: + settings = get_settings() + Path(settings.db_path).parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(settings.db_path) + conn.row_factory = sqlite3.Row + try: + yield conn + conn.commit() + finally: + conn.close() + + +def init_db() -> None: + with _conn() as conn: + conn.executescript(SCHEMA) + + +def upsert_lead( + nombre: str, + email: str, + empresa: str, + ip: str | None, + user_agent: str | None, + consent: bool, +) -> int: + """Insert a new lead, or update the existing row if email already exists. + + Returns the lead's id. Increments times_registered on duplicates. + """ + with _conn() as conn: + cur = conn.execute( + """ + INSERT INTO leads (nombre, email, empresa, ip, user_agent, consent) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(email) DO UPDATE SET + nombre = excluded.nombre, + empresa = excluded.empresa, + ip = excluded.ip, + user_agent = excluded.user_agent, + consent = excluded.consent, + times_registered = times_registered + 1, + last_seen = CURRENT_TIMESTAMP + RETURNING id + """, + (nombre, email, empresa, ip, user_agent, 1 if consent else 0), + ) + return cur.fetchone()["id"] + + +def get_lead_by_email(email: str) -> dict | None: + with _conn() as conn: + row = conn.execute( + "SELECT * FROM leads WHERE email = ?", (email,) + ).fetchone() + return dict(row) if row else None + + +def log_download(lead_email: str, filename: str, ip: str | None) -> None: + with _conn() as conn: + conn.execute( + "INSERT INTO downloads (lead_email, filename, ip) VALUES (?, ?, ?)", + (lead_email, filename, ip), + ) + + +def list_leads(limit: int = 100, offset: int = 0) -> list[dict]: + with _conn() as conn: + rows = conn.execute( + "SELECT * FROM leads ORDER BY id ASC LIMIT ? OFFSET ?", + (limit, offset), + ).fetchall() + return [dict(r) for r in rows] + + +def stats() -> dict: + with _conn() as conn: + total_leads = conn.execute("SELECT COUNT(*) AS c FROM leads").fetchone()["c"] + total_downloads = conn.execute("SELECT COUNT(*) AS c FROM downloads").fetchone()["c"] + per_file = { + r["filename"]: r["c"] + for r in conn.execute( + "SELECT filename, COUNT(*) AS c FROM downloads GROUP BY filename" + ).fetchall() + } + top_empresas = [ + {"empresa": r["empresa"], "count": r["c"]} + for r in conn.execute( + "SELECT empresa, COUNT(*) AS c FROM leads GROUP BY empresa ORDER BY c DESC LIMIT 5" + ).fetchall() + ] + return { + "total_leads": total_leads, + "total_downloads": total_downloads, + "downloads_por_archivo": per_file, + "top_5_empresas": top_empresas, + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +pytest tests/test_db.py -v +``` + +Expected: `5 passed`. + +- [ ] **Step 5: Commit** + +```bash +git add app/db.py tests/test_db.py +git commit -m "feat(db): sqlite schema + lead/download helpers with upsert-on-email" +``` + +--- + +## Task 4: Security (tokens, basic auth, honeypot) + +**Files:** +- Create: `app/security.py` +- Create: `tests/test_security.py` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/test_security.py +import time + +import pytest +from itsdangerous import BadSignature, SignatureExpired + +from app.security import ( + create_download_token, + verify_download_token, + is_honeypot_filled, +) + + +def test_token_roundtrip(): + token = create_download_token(email="x@y.com", nombre="X") + data = verify_download_token(token) + assert data["email"] == "x@y.com" + assert data["nombre"] == "X" + + +def test_token_tampering_raises(): + token = create_download_token(email="x@y.com", nombre="X") + tampered = token[:-3] + "AAA" + with pytest.raises(BadSignature): + verify_download_token(tampered) + + +def test_token_expiry(monkeypatch): + monkeypatch.setenv("TOKEN_EXPIRY_HOURS", "0") # expire immediately + from app import config + config.get_settings.cache_clear() + token = create_download_token(email="x@y.com", nombre="X") + time.sleep(1.1) + with pytest.raises(SignatureExpired): + verify_download_token(token) + config.get_settings.cache_clear() + + +def test_honeypot_empty_returns_false(): + assert is_honeypot_filled("") is False + assert is_honeypot_filled(None) is False + + +def test_honeypot_filled_returns_true(): + assert is_honeypot_filled("https://spam.com") is True + assert is_honeypot_filled("anything") is True +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pytest tests/test_security.py -v +``` + +Expected: `ImportError` on `app.security`. + +- [ ] **Step 3: Implement `app/security.py`** + +```python +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from itsdangerous import URLSafeTimedSerializer +import secrets + +from app.config import get_settings + + +_basic = HTTPBasic() + + +def _serializer() -> URLSafeTimedSerializer: + return URLSafeTimedSerializer(get_settings().secret_key, salt="download") + + +def create_download_token(email: str, nombre: str) -> str: + return _serializer().dumps({"email": email, "nombre": nombre}) + + +def verify_download_token(token: str) -> dict: + """Returns {'email': ..., 'nombre': ...}. Raises SignatureExpired or BadSignature.""" + settings = get_settings() + max_age_seconds = max(1, settings.token_expiry_hours * 3600) + return _serializer().loads(token, max_age=max_age_seconds) + + +def is_honeypot_filled(value: str | None) -> bool: + return bool(value) + + +def require_admin(credentials: HTTPBasicCredentials = Depends(_basic)) -> str: + """FastAPI dependency: enforces HTTP Basic auth against env-configured admin creds.""" + settings = get_settings() + user_ok = secrets.compare_digest(credentials.username, settings.admin_user) + pass_ok = secrets.compare_digest(credentials.password, settings.admin_pass) + if not (user_ok and pass_ok): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials", + headers={"WWW-Authenticate": "Basic"}, + ) + return credentials.username +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +pytest tests/test_security.py -v +``` + +Expected: `5 passed`. + +- [ ] **Step 5: Commit** + +```bash +git add app/security.py tests/test_security.py +git commit -m "feat(security): signed download tokens, basic-auth dep, honeypot check" +``` + +--- + +## Task 5: Static datasets for the workshop API + +This task creates the data files the 5 endpoints read at boot. Coherence between files matters (see design doc §6). + +**Files:** +- Create: `app/data/member_insights.json` +- Create: `app/data/schedule_response.txt` +- Create: `app/data/combined_email.txt` +- Create: `app/data/provider_email.txt` +- Create: `app/data/aetna_email.txt` +- Create: `app/data/aetna_claim_review_summary.csv` +- Create: `app/data/historical_procedures.csv` +- Create: `app/data/available_procedures.csv` +- Create: `app/data/reports_output/.gitkeep` (empty) + +- [ ] **Step 1: Write `app/data/member_insights.json`** + +Dates re-calibrated for today (2026-05-12): + +```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-2025.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": "2024-05-15", + "recommended_frequency_months": 12, + "due_since_months": 12, + "priority": "high" + }, + { + "procedure": "Dental Cleaning", + "last_date": "2025-01-10", + "recommended_frequency_months": 6, + "due_since_months": 10, + "priority": "medium" + }, + { + "procedure": "Vision Exam", + "last_date": "2023-08-22", + "recommended_frequency_months": 24, + "due_since_months": 8, + "priority": "medium" + }, + { + "procedure": "Blood Test - Cholesterol Panel", + "last_date": "2024-11-04", + "recommended_frequency_months": 12, + "due_since_months": 6, + "priority": "low" + } + ] + } +} +``` + +- [ ] **Step 2: Write `app/data/schedule_response.txt`** + +Single line (no JSON wrapper — endpoint adds that): + +```text +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. +``` + +- [ ] **Step 3: Write `app/data/combined_email.txt`** + +```text +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. + +Best, +Dr. Martinez + +--- + +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? + +Charlie +``` + +- [ ] **Step 4: Write `app/data/provider_email.txt`** + +```text +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. +Patient stable, NPO since midnight. Will reassess in 2h. +``` + +- [ ] **Step 5: Write `app/data/aetna_email.txt`** + +```text +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 +EOB available at member portal. +``` + +- [ ] **Step 6: Write `app/data/aetna_claim_review_summary.csv`** + +```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 +2024-01-18,99395,Preventive visit adult,310.00,250.00,250.00,0.00 +2023-12-05,90686,Influenza vaccine,55.00,40.00,40.00,0.00 +2023-11-12,87880,Strep A direct test,42.00,28.00,22.40,5.60 +2023-10-08,71046,Chest X-ray 2 views,210.00,140.00,112.00,28.00 +2023-09-22,93000,Electrocardiogram complete,138.00,90.00,72.00,18.00 +2023-08-14,99214,Office visit established mod complexity,250.00,170.00,136.00,34.00 +2023-07-03,36415,Venipuncture,18.00,12.00,9.60,2.40 +``` + +- [ ] **Step 7: Write `app/data/historical_procedures.csv`** + +50 rows. Charlie Smith's rows MUST line up with `member_insights.json`'s `overdue_procedures` (matching `last_date`). + +Columns (exact order): `member_name,relationship,age,gender,procedure,procedure_type,location,date,in_network,member_plan,accepted_plans,cost_facility,cost_physician,cost_anesthesia,cost_medication,total_cost,facility_rating,notes` + +Write this exact CSV (Charlie Smith rows first 4 — coherent with `member_insights.json`; rest are filler): + +```csv +member_name,relationship,age,gender,procedure,procedure_type,location,date,in_network,member_plan,accepted_plans,cost_facility,cost_physician,cost_anesthesia,cost_medication,total_cost,facility_rating,notes +Charlie Smith,Self,13,Male,Annual Physical Exam,preventive,City Hospital,2024-05-15,true,Gold PPO,"Gold PPO, Medicare Advantage",80.00,180.00,0.00,5.00,265.00,4.7,"Annual Physical Exam performed at City Hospital." +Charlie Smith,Self,13,Male,Dental Cleaning,preventive,Green Valley Clinic,2025-01-10,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",60.00,120.00,0.00,0.00,180.00,4.5,"Dental Cleaning performed at Green Valley Clinic." +Charlie Smith,Self,13,Male,Vision Exam,preventive,Sunrise Health,2023-08-22,true,Gold PPO,"Gold PPO, Bronze HDHP",40.00,110.00,0.00,0.00,150.00,4.3,"Vision Exam performed at Sunrise Health." +Charlie Smith,Self,13,Male,Blood Test,diagnostic,City Hospital,2024-11-04,true,Gold PPO,"Gold PPO, Medicare Advantage",25.00,55.00,0.00,2.50,82.50,4.7,"Cholesterol Panel performed at City Hospital." +Alice Smith,Spouse,42,Female,Annual Physical Exam,preventive,Regional Medical Center,2024-09-18,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",95.00,210.00,0.00,4.50,309.50,4.6,"Annual Physical Exam performed at Regional Medical Center." +Alice Smith,Spouse,42,Female,MRI,diagnostic,City Hospital,2023-11-02,true,Gold PPO,"Gold PPO, Medicare Advantage",1450.00,820.00,0.00,12.00,2282.00,4.7,"Lumbar MRI performed at City Hospital." +Alice Smith,Spouse,42,Female,Dental Cleaning,preventive,Green Valley Clinic,2024-08-12,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",65.00,125.00,0.00,0.00,190.00,4.5,"Dental Cleaning performed at Green Valley Clinic." +Bob Johnson,Self,55,Male,Appendectomy,surgery,Regional Medical Center,2024-02-04,true,Family Plan - Silver EPO,"Family Plan - Silver EPO, Gold PPO",8200.00,5400.00,2100.00,180.00,15880.00,4.6,"Laparoscopic appendectomy performed at Regional Medical Center." +Bob Johnson,Self,55,Male,Annual Physical Exam,preventive,Regional Medical Center,2024-10-22,false,Family Plan - Silver EPO,"Gold PPO",120.00,260.00,0.00,6.00,386.00,4.6,"Annual Physical Exam performed at Regional Medical Center." +Bob Johnson,Self,55,Male,CT Scan,diagnostic,City Hospital,2024-02-03,true,Family Plan - Silver EPO,"Gold PPO, Medicare Advantage",1600.00,750.00,0.00,8.00,2358.00,4.7,"Pre-op CT abdomen/pelvis at City Hospital." +Diana Roberts,Mother,67,Female,Annual Physical Exam,preventive,Sunrise Health,2024-07-09,true,Gold PPO,"Gold PPO, Bronze HDHP",95.00,220.00,0.00,5.00,320.00,4.3,"Annual Physical Exam performed at Sunrise Health." +Diana Roberts,Mother,67,Female,Vision Exam,preventive,Sunrise Health,2024-10-14,true,Gold PPO,"Gold PPO, Bronze HDHP",40.00,115.00,0.00,0.00,155.00,4.3,"Vision Exam performed at Sunrise Health." +Diana Roberts,Mother,67,Female,Blood Test,diagnostic,City Hospital,2025-02-18,true,Gold PPO,"Gold PPO, Medicare Advantage",28.00,60.00,0.00,3.00,91.00,4.7,"Cholesterol Panel performed at City Hospital." +Diana Roberts,Mother,67,Female,MRI,diagnostic,Regional Medical Center,2023-06-30,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",1520.00,840.00,0.00,10.00,2370.00,4.6,"Knee MRI performed at Regional Medical Center." +Ethan Smith,Son,11,Male,Annual Physical Exam,preventive,City Hospital,2025-04-02,true,Gold PPO,"Gold PPO, Medicare Advantage",70.00,170.00,0.00,4.00,244.00,4.7,"Annual Physical Exam performed at City Hospital." +Ethan Smith,Son,11,Male,Dental Cleaning,preventive,Green Valley Clinic,2025-03-15,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",55.00,115.00,0.00,0.00,170.00,4.5,"Dental Cleaning performed at Green Valley Clinic." +Ethan Smith,Son,11,Male,Vision Exam,preventive,Sunrise Health,2024-12-08,true,Gold PPO,"Gold PPO, Bronze HDHP",35.00,105.00,0.00,0.00,140.00,4.3,"Vision Exam performed at Sunrise Health." +Ethan Smith,Son,11,Male,X Ray,diagnostic,City Hospital,2024-09-21,true,Gold PPO,"Gold PPO, Medicare Advantage",105.00,135.00,0.00,2.00,242.00,4.7,"Wrist X Ray performed at City Hospital." +Alice Smith,Spouse,42,Female,Blood Test,diagnostic,City Hospital,2024-04-19,true,Gold PPO,"Gold PPO, Medicare Advantage",30.00,58.00,0.00,3.50,91.50,4.7,"Lipid panel at City Hospital." +Alice Smith,Spouse,42,Female,Vision Exam,preventive,Sunrise Health,2024-06-11,true,Gold PPO,"Gold PPO, Bronze HDHP",38.00,108.00,0.00,0.00,146.00,4.3,"Vision Exam performed at Sunrise Health." +Bob Johnson,Self,55,Male,Dental Cleaning,preventive,Green Valley Clinic,2024-12-03,true,Family Plan - Silver EPO,"Family Plan - Silver EPO, Gold PPO",60.00,120.00,0.00,0.00,180.00,4.5,"Dental Cleaning performed at Green Valley Clinic." +Bob Johnson,Self,55,Male,Blood Test,diagnostic,Regional Medical Center,2025-01-22,true,Family Plan - Silver EPO,"Family Plan - Silver EPO, Gold PPO",28.00,55.00,0.00,3.00,86.00,4.6,"A1C panel performed at Regional Medical Center." +Charlie Smith,Self,13,Male,X Ray,diagnostic,City Hospital,2025-03-28,true,Gold PPO,"Gold PPO, Medicare Advantage",110.00,140.00,0.00,2.00,252.00,4.7,"Ankle X Ray performed at City Hospital." +Frank Lee,Father,72,Male,CT Scan,diagnostic,Regional Medical Center,2024-08-29,false,Bronze HDHP,"Gold PPO, Medicare Advantage",1750.00,820.00,0.00,9.00,2579.00,4.6,"Head CT performed at Regional Medical Center." +Frank Lee,Father,72,Male,Annual Physical Exam,preventive,Sunrise Health,2024-11-19,true,Bronze HDHP,"Gold PPO, Bronze HDHP",95.00,225.00,0.00,5.50,325.50,4.3,"Annual Physical Exam performed at Sunrise Health." +Frank Lee,Father,72,Male,Vision Exam,preventive,Sunrise Health,2024-09-02,true,Bronze HDHP,"Gold PPO, Bronze HDHP",42.00,118.00,0.00,0.00,160.00,4.3,"Vision Exam performed at Sunrise Health." +Frank Lee,Father,72,Male,Blood Test,diagnostic,City Hospital,2025-02-25,true,Bronze HDHP,"Gold PPO, Medicare Advantage",30.00,58.00,0.00,4.00,92.00,4.7,"Comprehensive metabolic panel at City Hospital." +Grace Wong,Daughter,16,Female,Annual Physical Exam,preventive,Green Valley Clinic,2025-02-11,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",75.00,175.00,0.00,4.00,254.00,4.5,"Annual Physical Exam performed at Green Valley Clinic." +Grace Wong,Daughter,16,Female,Dental Cleaning,preventive,Green Valley Clinic,2024-11-30,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",58.00,118.00,0.00,0.00,176.00,4.5,"Dental Cleaning performed at Green Valley Clinic." +Grace Wong,Daughter,16,Female,Vision Exam,preventive,Sunrise Health,2025-01-26,true,Gold PPO,"Gold PPO, Bronze HDHP",36.00,106.00,0.00,0.00,142.00,4.3,"Vision Exam performed at Sunrise Health." +Henry Park,Self,38,Male,MRI,diagnostic,City Hospital,2024-07-15,true,Family Plan - Silver EPO,"Gold PPO, Family Plan - Silver EPO",1500.00,830.00,0.00,11.00,2341.00,4.7,"Shoulder MRI performed at City Hospital." +Henry Park,Self,38,Male,Annual Physical Exam,preventive,Regional Medical Center,2024-08-08,true,Family Plan - Silver EPO,"Gold PPO, Family Plan - Silver EPO",90.00,210.00,0.00,5.00,305.00,4.6,"Annual Physical Exam performed at Regional Medical Center." +Henry Park,Self,38,Male,X Ray,diagnostic,City Hospital,2024-07-10,true,Family Plan - Silver EPO,"Gold PPO, Medicare Advantage",120.00,145.00,0.00,2.50,267.50,4.7,"Shoulder X Ray performed at City Hospital." +Henry Park,Self,38,Male,Dental Cleaning,preventive,Green Valley Clinic,2024-10-04,true,Family Plan - Silver EPO,"Gold PPO, Family Plan - Silver EPO",62.00,122.00,0.00,0.00,184.00,4.5,"Dental Cleaning performed at Green Valley Clinic." +Isabel Cruz,Mother,69,Female,Appendectomy,surgery,City Hospital,2024-03-12,true,Gold PPO,"Gold PPO, Medicare Advantage",9100.00,5800.00,2200.00,195.00,17295.00,4.7,"Open appendectomy performed at City Hospital." +Isabel Cruz,Mother,69,Female,CT Scan,diagnostic,City Hospital,2024-03-11,true,Gold PPO,"Gold PPO, Medicare Advantage",1620.00,780.00,0.00,8.50,2408.50,4.7,"Pre-op CT abdomen/pelvis at City Hospital." +Isabel Cruz,Mother,69,Female,Annual Physical Exam,preventive,Sunrise Health,2024-06-22,true,Gold PPO,"Gold PPO, Bronze HDHP",95.00,225.00,0.00,5.50,325.50,4.3,"Annual Physical Exam performed at Sunrise Health." +Isabel Cruz,Mother,69,Female,Blood Test,diagnostic,City Hospital,2025-01-30,true,Gold PPO,"Gold PPO, Medicare Advantage",32.00,62.00,0.00,3.50,97.50,4.7,"Comprehensive metabolic panel at City Hospital." +Jack Miller,Self,29,Male,X Ray,diagnostic,Sunrise Health,2024-05-04,true,Bronze HDHP,"Gold PPO, Bronze HDHP",95.00,130.00,0.00,2.00,227.00,4.3,"Knee X Ray performed at Sunrise Health." +Jack Miller,Self,29,Male,Annual Physical Exam,preventive,Sunrise Health,2025-02-06,true,Bronze HDHP,"Gold PPO, Bronze HDHP",85.00,200.00,0.00,4.50,289.50,4.3,"Annual Physical Exam performed at Sunrise Health." +Jack Miller,Self,29,Male,Dental Cleaning,preventive,Green Valley Clinic,2024-10-17,true,Bronze HDHP,"Family Plan - Silver EPO",58.00,118.00,0.00,0.00,176.00,4.5,"Dental Cleaning performed at Green Valley Clinic." +Karen Davis,Spouse,46,Female,MRI,diagnostic,Regional Medical Center,2024-04-09,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",1480.00,810.00,0.00,11.00,2301.00,4.6,"Brain MRI performed at Regional Medical Center." +Karen Davis,Spouse,46,Female,Annual Physical Exam,preventive,Regional Medical Center,2024-11-25,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",100.00,225.00,0.00,5.50,330.50,4.6,"Annual Physical Exam performed at Regional Medical Center." +Karen Davis,Spouse,46,Female,Dental Cleaning,preventive,Green Valley Clinic,2024-09-30,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",60.00,120.00,0.00,0.00,180.00,4.5,"Dental Cleaning performed at Green Valley Clinic." +Liam O'Brien,Son,9,Male,Annual Physical Exam,preventive,City Hospital,2025-03-05,true,Gold PPO,"Gold PPO, Medicare Advantage",70.00,170.00,0.00,4.00,244.00,4.7,"Annual Physical Exam performed at City Hospital." +Liam O'Brien,Son,9,Male,Vision Exam,preventive,Sunrise Health,2024-12-19,true,Gold PPO,"Gold PPO, Bronze HDHP",35.00,105.00,0.00,0.00,140.00,4.3,"Vision Exam performed at Sunrise Health." +Liam O'Brien,Son,9,Male,Dental Cleaning,preventive,Green Valley Clinic,2025-04-11,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",55.00,115.00,0.00,0.00,170.00,4.5,"Dental Cleaning performed at Green Valley Clinic." +Maria Lopez,Daughter,19,Female,Annual Physical Exam,preventive,Green Valley Clinic,2025-01-08,true,Family Plan - Silver EPO,"Gold PPO, Family Plan - Silver EPO",80.00,185.00,0.00,4.50,269.50,4.5,"Annual Physical Exam performed at Green Valley Clinic." +Maria Lopez,Daughter,19,Female,Blood Test,diagnostic,City Hospital,2025-02-14,true,Family Plan - Silver EPO,"Gold PPO, Medicare Advantage",28.00,55.00,0.00,3.00,86.00,4.7,"Iron panel performed at City Hospital." +Maria Lopez,Daughter,19,Female,Vision Exam,preventive,Sunrise Health,2024-08-20,true,Family Plan - Silver EPO,"Gold PPO, Bronze HDHP",38.00,108.00,0.00,0.00,146.00,4.3,"Vision Exam performed at Sunrise Health." +``` + +- [ ] **Step 8: Write `app/data/available_procedures.csv`** + +28 rows covering MRI, CT Scan, X Ray, Annual Physical Exam, Appendectomy, Dental Cleaning, Vision Exam, Blood Test, Angioplasty, Ultrasound across 4 locations: + +Columns: `procedure,location,facility_rating,distance_miles,gold_ppo_plan_accepted,silver_epo_plan_accepted,accepted_plans,cost_facility,cost_physician,cost_anesthesia,cost_medication,total_cost` + +```csv +procedure,location,facility_rating,distance_miles,gold_ppo_plan_accepted,silver_epo_plan_accepted,accepted_plans,cost_facility,cost_physician,cost_anesthesia,cost_medication,total_cost +MRI,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",1450.00,820.00,0.00,12.00,2282.00 +MRI,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",1380.00,790.00,0.00,11.00,2181.00 +MRI,Green Valley Clinic,4.5,8.4,true,false,"Gold PPO, Bronze HDHP",1520.00,860.00,0.00,12.50,2392.50 +MRI,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",1610.00,880.00,0.00,13.00,2503.00 +CT Scan,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",1600.00,750.00,0.00,8.00,2358.00 +CT Scan,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",1540.00,720.00,0.00,7.50,2267.50 +CT Scan,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",1750.00,820.00,0.00,9.00,2579.00 +X Ray,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",110.00,140.00,0.00,2.00,252.00 +X Ray,Green Valley Clinic,4.5,8.4,true,true,"Gold PPO, Family Plan - Silver EPO, Bronze HDHP",95.00,125.00,0.00,1.50,221.50 +X Ray,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",90.00,120.00,0.00,1.50,211.50 +X Ray,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",100.00,130.00,0.00,1.50,231.50 +Annual Physical Exam,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",80.00,180.00,0.00,5.00,265.00 +Annual Physical Exam,Green Valley Clinic,4.5,8.4,true,true,"Gold PPO, Family Plan - Silver EPO, Bronze HDHP",75.00,170.00,0.00,4.50,249.50 +Annual Physical Exam,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",85.00,200.00,0.00,4.50,289.50 +Annual Physical Exam,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",90.00,210.00,0.00,5.00,305.00 +Appendectomy,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",9100.00,5800.00,2200.00,195.00,17295.00 +Appendectomy,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",8200.00,5400.00,2100.00,180.00,15880.00 +Dental Cleaning,Green Valley Clinic,4.5,8.4,true,true,"Gold PPO, Family Plan - Silver EPO, Bronze HDHP",58.00,118.00,0.00,0.00,176.00 +Dental Cleaning,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO",60.00,120.00,0.00,0.00,180.00 +Dental Cleaning,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",55.00,115.00,0.00,0.00,170.00 +Vision Exam,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",35.00,105.00,0.00,0.00,140.00 +Vision Exam,Green Valley Clinic,4.5,8.4,true,true,"Gold PPO, Family Plan - Silver EPO, Bronze HDHP",38.00,108.00,0.00,0.00,146.00 +Vision Exam,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO",40.00,110.00,0.00,0.00,150.00 +Blood Test,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",25.00,55.00,0.00,2.50,82.50 +Blood Test,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",28.00,55.00,0.00,3.00,86.00 +Angioplasty,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",9432.80,4774.57,1894.37,834.80,16936.54 +Angioplasty,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",8920.00,4520.00,1850.00,810.00,16100.00 +Ultrasound,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",380.00,290.00,0.00,3.00,673.00 +``` + +- [ ] **Step 9: Create `app/data/reports_output/.gitkeep`** + +Empty file. Run: +```bash +mkdir -p app/data/reports_output && touch app/data/reports_output/.gitkeep +``` + +- [ ] **Step 10: Sanity-check the CSVs load with pandas** + +Run a quick REPL check: +```bash +python3.11 -c " +import pandas as pd +h = pd.read_csv('app/data/historical_procedures.csv') +a = pd.read_csv('app/data/available_procedures.csv') +c = pd.read_csv('app/data/aetna_claim_review_summary.csv') +print('historical:', len(h), 'rows,', list(h.columns)[:5], '...') +print('available:', len(a), 'rows,', list(a.columns)[:5], '...') +print('aetna:', len(c), 'rows,', list(c.columns)[:5], '...') +charlie = h[h['member_name'] == 'Charlie Smith'] +print('Charlie rows:', len(charlie)) +assert len(charlie) >= 4, 'Charlie should have ≥4 rows (matches member_insights overdue list)' +" +``` + +Expected: `historical: 50 rows`, `available: 28 rows`, `aetna: 10 rows`, `Charlie rows: 5`. + +- [ ] **Step 11: Commit** + +```bash +git add app/data/ +git commit -m "feat(data): synthetic datasets for the 5 workshop endpoints" +``` + +--- + +## Task 6: Endpoints 3 (member-insights) and 4 (schedule) + +These are static — no logic. Implementing them first builds confidence in the routing setup. + +**Files:** +- Create: `app/benefits_api.py` +- Modify: `app/main.py` +- Create: `tests/test_benefits_api.py` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/test_benefits_api.py +def test_member_insights_returns_static_object(client): + response = client.get("/api/member-insights") + assert response.status_code == 200 + body = response.json() + assert "result" in body + assert body["result"]["member"]["name"] == "Charlie Smith" + assert body["result"]["member"]["plan"] == "Gold PPO" + assert len(body["result"]["overdue_procedures"]) == 4 + + +def test_schedule_returns_text_wrapped(client): + response = client.get("/api/schedule") + assert response.status_code == 200 + body = response.json() + assert "result" in body + assert "agendar una cita" in body["result"].lower() + assert "1-800-FIT-CARE" in body["result"] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pytest tests/test_benefits_api.py -v +``` + +Expected: 404 (routes don't exist yet). + +- [ ] **Step 3: Implement `app/benefits_api.py`** + +```python +import json +from pathlib import Path + +from fastapi import APIRouter + +router = APIRouter(prefix="/api", tags=["benefits"]) + +_DATA_DIR = Path(__file__).parent / "data" + +with (_DATA_DIR / "member_insights.json").open() as f: + _MEMBER_INSIGHTS = json.load(f) + +_SCHEDULE_TEXT = (_DATA_DIR / "schedule_response.txt").read_text(encoding="utf-8").strip() + + +@router.get("/member-insights") +def member_insights(): + return _MEMBER_INSIGHTS + + +@router.get("/schedule") +def schedule(): + return {"result": _SCHEDULE_TEXT} +``` + +- [ ] **Step 4: Wire router into `app/main.py`** + +Add to imports: +```python +from app import benefits_api +``` + +Add after `app.add_middleware(...)`: +```python +app.include_router(benefits_api.router) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +pytest tests/test_benefits_api.py -v +``` + +Expected: `2 passed`. + +- [ ] **Step 6: Commit** + +```bash +git add app/benefits_api.py app/main.py tests/test_benefits_api.py +git commit -m "feat(api): endpoints 3 and 4 - member-insights and schedule" +``` + +--- + +## Task 7: Endpoint 1 (historical-procedures) + +Adds the filter + group_by logic. Shared with endpoint 2 in Task 8. + +**Files:** +- Modify: `app/benefits_api.py` +- Modify: `tests/test_benefits_api.py` + +- [ ] **Step 1: Write failing tests** + +Append to `tests/test_benefits_api.py`: + +```python +def test_historical_procedures_no_filter_returns_all(client): + response = client.post( + "/api/historical-procedures", + json={"filters": "[]", "group_by": "[]"}, + ) + assert response.status_code == 200 + body = response.json() + assert "result" in body + assert len(body["result"]) == 50 + + +def test_historical_procedures_filter_equals(client): + response = client.post( + "/api/historical-procedures", + json={ + "filters": '[{"column": "member_name", "operator": "equals", "value": "Charlie Smith"}]', + "group_by": "[]", + }, + ) + assert response.status_code == 200 + rows = response.json()["result"] + assert len(rows) == 5 # Charlie Smith has 5 rows in the dataset + assert all(r["member_name"] == "Charlie Smith" for r in rows) + + +def test_historical_procedures_filter_contains(client): + response = client.post( + "/api/historical-procedures", + json={ + "filters": '[{"column": "procedure", "operator": "contains", "value": "mri"}]', + "group_by": "[]", + }, + ) + assert response.status_code == 200 + rows = response.json()["result"] + assert len(rows) > 0 + assert all("MRI" in r["procedure"] for r in rows) + + +def test_historical_procedures_filter_gt(client): + response = client.post( + "/api/historical-procedures", + json={ + "filters": '[{"column": "total_cost", "operator": "gt", "value": 5000}]', + "group_by": "[]", + }, + ) + assert response.status_code == 200 + rows = response.json()["result"] + assert len(rows) > 0 + assert all(r["total_cost"] > 5000 for r in rows) + + +def test_historical_procedures_group_by(client): + response = client.post( + "/api/historical-procedures", + json={"filters": "[]", "group_by": '["relationship"]'}, + ) + assert response.status_code == 200 + rows = response.json()["result"] + assert len(rows) > 0 + # group_by should leave only group key + aggregated numeric cols + assert "relationship" in rows[0] + assert "age" in rows[0] +``` + +- [ ] **Step 2: Run tests — expect failures** + +```bash +pytest tests/test_benefits_api.py -v +``` + +Expected: 4 new tests fail (404). + +- [ ] **Step 3: Implement filter+group_by helper and route** + +Replace `app/benefits_api.py` with: + +```python +import json +from pathlib import Path + +import pandas as pd +from fastapi import APIRouter, HTTPException + +router = APIRouter(prefix="/api", tags=["benefits"]) + +_DATA_DIR = Path(__file__).parent / "data" + +with (_DATA_DIR / "member_insights.json").open() as f: + _MEMBER_INSIGHTS = json.load(f) + +_SCHEDULE_TEXT = (_DATA_DIR / "schedule_response.txt").read_text(encoding="utf-8").strip() + +_HISTORICAL = pd.read_csv(_DATA_DIR / "historical_procedures.csv") + + +_OPS = { + "equals": lambda s, v: s == v, + "ne": lambda s, v: s != v, + "contains": lambda s, v: s.astype(str).str.contains(str(v), case=False, na=False), + "gt": lambda s, v: s > v, + "lt": lambda s, v: s < v, + "ge": lambda s, v: s >= v, + "le": lambda s, v: s <= v, +} + + +def _apply_filters_and_group(df: pd.DataFrame, filters_raw: str, group_by_raw: str) -> list[dict]: + try: + filters = json.loads(filters_raw) if filters_raw else [] + group_by = json.loads(group_by_raw) if group_by_raw else [] + except json.JSONDecodeError as exc: + raise HTTPException(status_code=400, detail=f"Invalid JSON in filters or group_by: {exc}") + + result = df.copy() + for f in filters: + col, op, val = f.get("column"), f.get("operator"), f.get("value") + if col not in result.columns: + raise HTTPException(status_code=400, detail=f"Unknown column: {col}") + if op not in _OPS: + raise HTTPException(status_code=400, detail=f"Unsupported operator: {op}") + result = result[_OPS[op](result[col], val)] + + if group_by: + missing = [c for c in group_by if c not in result.columns] + if missing: + raise HTTPException(status_code=400, detail=f"Unknown group_by columns: {missing}") + numeric_cols = result.select_dtypes(include="number").columns.tolist() + result = result.groupby(group_by)[numeric_cols].mean().reset_index() + + return result.to_dict(orient="records") + + +@router.get("/member-insights") +def member_insights(): + return _MEMBER_INSIGHTS + + +@router.get("/schedule") +def schedule(): + return {"result": _SCHEDULE_TEXT} + + +@router.post("/historical-procedures") +def historical_procedures(payload: dict): + rows = _apply_filters_and_group( + _HISTORICAL, + payload.get("filters", "[]"), + payload.get("group_by", "[]"), + ) + return {"result": rows} +``` + +- [ ] **Step 4: Run tests — expect pass** + +```bash +pytest tests/test_benefits_api.py -v +``` + +Expected: `7 passed`. + +- [ ] **Step 5: Commit** + +```bash +git add app/benefits_api.py tests/test_benefits_api.py +git commit -m "feat(api): endpoint 1 - historical-procedures with filter+group_by" +``` + +--- + +## Task 8: Endpoint 2 (available-procedures) + +Reuses the helper from Task 7. + +**Files:** +- Modify: `app/benefits_api.py` +- Modify: `tests/test_benefits_api.py` + +- [ ] **Step 1: Write failing tests** + +Append to `tests/test_benefits_api.py`: + +```python +def test_available_procedures_no_filter(client): + response = client.post( + "/api/available-procedures", + json={"filters": "[]", "group_by": "[]"}, + ) + assert response.status_code == 200 + rows = response.json()["result"] + assert len(rows) == 28 + + +def test_available_procedures_filter_mri(client): + response = client.post( + "/api/available-procedures", + json={ + "filters": '[{"column": "procedure", "operator": "contains", "value": "MRI"}]', + "group_by": "[]", + }, + ) + rows = response.json()["result"] + assert len(rows) >= 3 + assert all(r["procedure"] == "MRI" for r in rows) + + +def test_available_procedures_group_by_procedure(client): + response = client.post( + "/api/available-procedures", + json={"filters": "[]", "group_by": '["procedure"]'}, + ) + rows = response.json()["result"] + assert len(rows) > 0 + assert "procedure" in rows[0] + assert "total_cost" in rows[0] # numeric column should be averaged +``` + +- [ ] **Step 2: Run tests — expect 404** + +```bash +pytest tests/test_benefits_api.py -v +``` + +Expected: 3 new tests fail (404). + +- [ ] **Step 3: Add the route to `app/benefits_api.py`** + +After the `_HISTORICAL = ...` line, add: +```python +_AVAILABLE = pd.read_csv(_DATA_DIR / "available_procedures.csv") +``` + +Append at the end of the file: +```python +@router.post("/available-procedures") +def available_procedures(payload: dict): + rows = _apply_filters_and_group( + _AVAILABLE, + payload.get("filters", "[]"), + payload.get("group_by", "[]"), + ) + return {"result": rows} +``` + +- [ ] **Step 4: Run tests — expect pass** + +```bash +pytest tests/test_benefits_api.py -v +``` + +Expected: `10 passed`. + +- [ ] **Step 5: Commit** + +```bash +git add app/benefits_api.py tests/test_benefits_api.py +git commit -m "feat(api): endpoint 2 - available-procedures (reuses filter helper)" +``` + +--- + +## Task 9: Endpoint 5 (generate-report) + +The trickiest. Generates HTML with Jinja2 + Plotly, writes to disk, returns public URL. + +**Files:** +- Create: `app/reports_api.py` +- Create: `app/templates/report.html` +- Modify: `app/main.py` +- Create: `tests/test_reports_api.py` + +- [ ] **Step 1: Write `app/templates/report.html`** + +```html + + + + + Care Report + + + + +

FACTORIT · FIT

+ {{ content | safe }} +
Generado por AskReporting · taller-wox.fitlabs.dev
+ + +``` + +- [ ] **Step 2: Write failing tests** + +```python +# tests/test_reports_api.py +import json +import os +from pathlib import Path + + +def test_generate_report_with_care_report_preset(client): + response = client.post( + "/api/reports/generate-report", + json={"layout_config": json.dumps(["care_report"])}, + ) + assert response.status_code == 200 + body = response.json() + assert "public_url" in body + assert body["public_url"].endswith(".html") + + # Get the report id and verify the file was written + report_id = body["public_url"].rsplit("/", 1)[-1] + output_dir = Path(os.environ["REPORTS_OUTPUT_DIR"]) + assert (output_dir / report_id).exists() + content = (output_dir / report_id).read_text() + assert "Care Report" in content + assert "Customer Overview" in content + assert "Claim Review Summary" in content + assert " Path: + """Resolve a path provided by the agent. The spec sends them like './data/foo.txt'.""" + cleaned = relative.lstrip("./") + if cleaned.startswith("data/"): + cleaned = cleaned[len("data/"):] + return _DATA_DIR / cleaned + + +def _render_header(params: dict) -> str: + title = params.get("title", "") + return f"

{title}

" + + +def _render_overview(params: dict) -> str: + title = params.get("title", "Overview") + text_file = _resolve_path(params.get("text_file", "")) + if not text_file.exists(): + raise HTTPException(status_code=400, detail=f"text_file not found: {params.get('text_file')}") + text = text_file.read_text(encoding="utf-8") + # Render text with line breaks. No LLM dependency — show raw + a synthesized summary heading. + paragraphs = "".join(f"

{line}

" for line in text.splitlines() if line.strip()) + return f"

{title}

{paragraphs}
" + + +def _render_claim_chart() -> str: + # Use the CSV to build a bar chart of charged vs allowed vs patient_responsibility per CPT + df = pd.read_csv(_DATA_DIR / "aetna_claim_review_summary.csv") + fig = px.bar( + df, + x="CPT_Code", + y=["Charged_Amount", "Allowed_Amount", "Patient_Responsibility"], + barmode="group", + title="Claim Review by CPT Code", + color_discrete_sequence=["#0A1F44", "#1E4FA8", "#FF7A00"], + ) + return pio.to_html(fig, full_html=False, include_plotlyjs=False) + + +def _render_table(params: dict) -> str: + title = params.get("title", "") + csv_file = _resolve_path(params.get("csv_file", "")) + if not csv_file.exists(): + raise HTTPException(status_code=400, detail=f"csv_file not found: {params.get('csv_file')}") + df = pd.read_csv(csv_file) + return f"

{title}

" + df.to_html(index=False, classes="report-table", border=0) + + +_RENDERERS = { + "header": _render_header, + "overview": _render_overview, + "claim_review_chart": lambda _params: _render_claim_chart(), + "table": _render_table, +} + + +@router.post("/generate-report") +def generate_report(payload: dict): + try: + layout = json.loads(payload["layout_config"]) + except (KeyError, json.JSONDecodeError) as exc: + raise HTTPException(status_code=400, detail=f"Invalid layout_config: {exc}") + + # Expand preset strings + expanded: list[dict] = [] + for item in layout: + if item == "care_report": + expanded.extend(CARE_REPORT_PRESET) + elif isinstance(item, dict): + expanded.append(item) + else: + raise HTTPException(status_code=400, detail=f"Unknown layout item: {item!r}") + + parts: list[str] = [] + for el in expanded: + kind = el.get("element_type") + if kind not in _RENDERERS: + raise HTTPException(status_code=400, detail=f"Unknown element_type: {kind}") + parts.append(_RENDERERS[kind](el.get("parameters", {}))) + + template = _env.get_template("report.html") + html = template.render(content="\n".join(parts)) + + settings = get_settings() + output_dir = Path(settings.reports_output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + report_id = f"{uuid.uuid4().hex[:12]}.html" + (output_dir / report_id).write_text(html, encoding="utf-8") + + return {"public_url": f"{settings.base_url}/api/reports/output/{report_id}"} +``` + +- [ ] **Step 5: Wire router and static mount into `app/main.py`** + +Add imports: +```python +from pathlib import Path + +from fastapi.staticfiles import StaticFiles + +from app import reports_api +``` + +After the existing `app.include_router(...)`, add: +```python +app.include_router(reports_api.router) + +# Serve generated reports as static files +_reports_dir = Path(settings.reports_output_dir) +_reports_dir.mkdir(parents=True, exist_ok=True) +app.mount( + "/api/reports/output", + StaticFiles(directory=str(_reports_dir)), + name="reports_output", +) +``` + +- [ ] **Step 6: Run tests — expect pass** + +```bash +pytest tests/test_reports_api.py -v +``` + +Expected: `3 passed`. + +- [ ] **Step 7: Quick manual smoke** + +```bash +uvicorn app.main:app --reload --port 8000 +``` + +In another terminal: +```bash +curl -X POST http://localhost:8000/api/reports/generate-report \ + -H "Content-Type: application/json" \ + -d '{"layout_config":"[\"care_report\"]"}' +``` + +Expected: JSON with a `public_url` ending in `.html`. Open the URL in a browser to verify the report renders with the chart. + +- [ ] **Step 8: Commit** + +```bash +git add app/reports_api.py app/templates/report.html app/main.py tests/test_reports_api.py +git commit -m "feat(api): endpoint 5 - generate-report with jinja2 + plotly" +``` + +--- + +## Task 10: Frontend templates and CSS (no backend routes yet) + +This task produces the HTML/CSS skeleton. Backend routes that serve them come in Task 11. + +**Files:** +- Create: `app/templates/base.html` +- Create: `app/templates/index.html` +- Create: `app/templates/descargas.html` +- Create: `static/css/styles.css` +- Create: `static/js/app.js` +- Create: `static/img/.gitkeep` + +- [ ] **Step 1: Compress the existing logo** + +```bash +mkdir -p static/img +# Copy first; compression is a nice-to-have, the PNG works as-is +cp images/LogoFIT.png static/img/LogoFIT.png +``` + +(If you have `pngquant` or similar installed, run `pngquant --quality=70-85 -o static/img/LogoFIT.png --force static/img/LogoFIT.png` to compress. Otherwise skip — the 971 KB PNG is acceptable for Thursday.) + +- [ ] **Step 2: Write `app/templates/base.html`** + +```html + + + + + + {% block title %}Bootcamp Agentic AI — watsonx Orchestrate | FactorIT{% endblock %} + + + + + + + + + + + + + + + + {% block content %}{% endblock %} + + + + + + +``` + +- [ ] **Step 3: Write `app/templates/index.html`** + +```html +{% extends "base.html" %} + +{% block content %} +{% if error %} + +{% endif %} + +
+
+

FACTORIT · FIT

+

Bootcamp Agentic AI con watsonx Orchestrate

+

Construye tu primer agente de IA en 4 horas.

+ Acceder al material → +
+
+ +
+

¿Qué vas a construir?

+
+
⚙️

Tu primer agente

Conecta una API real a un agente conversacional sin escribir código.

+
📚

Multi-agente con RAG

Compón agentes especializados con base de conocimiento documental.

+
📊

Reportes y APIs

Genera reportes ejecutivos invocando endpoints en vivo.

+
+
+ +
+

El taller en números

+
+
4hDuración
+
6Módulos
+
0Líneas de código
+
100%Hands-on
+
+
+ +
+

Descarga todo el material

+

Registra tus datos para acceder al kit completo del bootcamp.

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + + +
+
+{% endblock %} +``` + +- [ ] **Step 4: Write `app/templates/descargas.html`** + +```html +{% extends "base.html" %} +{% block title %}Material del bootcamp — {{ nombre }}{% endblock %} + +{% block content %} +
+

¡Hola {{ nombre }}!

+

Acá tienes todo el material del bootcamp.

+ +
+ {% for d in downloads %} +
+
{{ d.icon }}
+

{{ d.title }}

+

{{ d.description }}

+

{{ d.size_mb }} MB

+ {% if d.available %} + Descargar + {% else %} + No disponible + {% endif %} +
+ {% endfor %} +
+
+{% endblock %} +``` + +- [ ] **Step 5: Write `static/css/styles.css`** + +```css +:root { + --navy: #0A1F44; + --blue: #1E4FA8; + --cyan: #00B5D8; + --orange: #FF7A00; + --orange-hover: #E66A00; + --cream: #F5F7FA; + --text: #1A1A1A; + --muted: #5A6473; + --border: #D8DEE5; +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { margin: 0; font-family: 'Inter', system-ui, sans-serif; color: var(--text); background: white; line-height: 1.6; } + +.site-header { + display: flex; align-items: center; justify-content: space-between; + padding: 16px 32px; background: white; border-bottom: 1px solid var(--border); +} +.site-header .brand img { display: block; } +.powered-by { color: var(--muted); font-size: 13px; } +.powered-by strong { color: var(--navy); } + +.hero { + background: linear-gradient(135deg, var(--navy) 0%, var(--blue) 100%); + color: white; padding: 80px 32px; min-height: 80vh; display: flex; align-items: center; +} +.hero-inner { max-width: 800px; margin: 0 auto; text-align: center; } +.eyebrow { color: var(--cyan); font-weight: 700; letter-spacing: 2px; margin: 0 0 16px; } +.hero h1 { font-size: clamp(32px, 5vw, 56px); margin: 0 0 16px; font-weight: 800; line-height: 1.15; } +.subtitle { font-size: 20px; color: var(--cream); font-style: italic; margin: 0 0 32px; } + +.btn { display: inline-block; padding: 14px 28px; border-radius: 8px; font-weight: 700; text-decoration: none; cursor: pointer; border: 0; font-size: 16px; transition: transform 0.15s ease, background 0.15s ease; } +.btn-primary { background: var(--orange); color: white; } +.btn-primary:hover { background: var(--orange-hover); transform: translateY(-1px); } +.btn-large { padding: 18px 36px; font-size: 18px; } +.btn-disabled { background: var(--border); color: var(--muted); cursor: not-allowed; } + +.section-title { text-align: center; font-size: 32px; color: var(--navy); margin: 0 0 32px; } + +.cards-section, .stats-section, .form-section, .descargas-section { + padding: 64px 32px; max-width: 1100px; margin: 0 auto; +} +.cards-section { background: var(--cream); max-width: none; } +.cards-section .section-title, .cards-section .cards { max-width: 1100px; margin-left: auto; margin-right: auto; } + +.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 24px; } +.card { background: white; padding: 32px; border-radius: 12px; box-shadow: 0 2px 8px rgba(10, 31, 68, 0.08); transition: transform 0.2s ease, box-shadow 0.2s ease; } +.card:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(10, 31, 68, 0.14); } +.card .icon { font-size: 36px; margin-bottom: 12px; } +.card h3 { color: var(--navy); margin: 0 0 8px; } +.card p { color: var(--muted); margin: 0; } + +.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 24px; text-align: center; } +.stat { padding: 24px; } +.stat .num { display: block; font-size: 56px; font-weight: 800; color: var(--orange); line-height: 1; } +.stat .label { display: block; color: var(--muted); margin-top: 8px; font-size: 14px; text-transform: uppercase; letter-spacing: 1px; } + +.form-section { background: white; max-width: 600px; } +.form-intro { text-align: center; color: var(--muted); margin: 0 0 32px; } +.register-form { display: flex; flex-direction: column; gap: 20px; } +.field label { display: block; font-weight: 600; margin-bottom: 6px; color: var(--navy); } +.field input[type="text"], .field input[type="email"] { + width: 100%; padding: 12px 14px; border: 2px solid var(--border); border-radius: 8px; font-size: 16px; font-family: inherit; +} +.field input:focus { outline: none; border-color: var(--cyan); } +.field-checkbox { display: flex; gap: 12px; align-items: flex-start; } +.field-checkbox input { margin-top: 4px; } +.field-checkbox label { font-weight: 400; color: var(--muted); font-size: 14px; } +.hp-field { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden; } + +.banner { padding: 16px 32px; text-align: center; } +.banner-error { background: #FFF1F0; color: #B0211A; border-bottom: 2px solid #B0211A; } + +.descargas-section { text-align: center; } +.hello { color: var(--navy); font-size: 40px; margin: 0 0 8px; } +.hello-sub { color: var(--muted); margin: 0 0 48px; } +.download-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 32px; } +.download-card { background: var(--cream); padding: 40px; border-radius: 16px; border: 2px solid transparent; transition: border-color 0.2s ease, transform 0.2s ease; } +.download-card:hover { border-color: var(--cyan); transform: translateY(-2px); } +.download-card .dl-icon { font-size: 56px; margin-bottom: 16px; } +.download-card h2 { color: var(--navy); margin: 0 0 8px; font-size: 24px; } +.download-card p { color: var(--muted); margin: 0 0 8px; } +.download-card .size { font-size: 13px; color: var(--muted); font-weight: 600; } + +.site-footer { background: var(--navy); color: white; padding: 32px; margin-top: 64px; } +.footer-inner { max-width: 1100px; margin: 0 auto; text-align: center; } +.footer-inner a { color: var(--cyan); text-decoration: none; } +.copyright { color: rgba(255, 255, 255, 0.5); font-size: 13px; margin-top: 16px; } + +@media (max-width: 768px) { + .hero { min-height: 70vh; padding: 48px 24px; } + .cards-section, .stats-section, .form-section, .descargas-section { padding: 48px 24px; } + .download-card { padding: 24px; } +} +``` + +- [ ] **Step 6: Write `static/js/app.js` (minimal)** + +```javascript +// Smooth scroll for in-page anchors is already handled by html { scroll-behavior: smooth }. +// Light enhancement: fade-in sections as they enter the viewport. + +document.addEventListener('DOMContentLoaded', () => { + const sections = document.querySelectorAll('.cards-section, .stats-section, .form-section'); + if (!('IntersectionObserver' in window)) return; + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + entry.target.style.opacity = '1'; + entry.target.style.transform = 'translateY(0)'; + observer.unobserve(entry.target); + } + }); + }, { threshold: 0.1 }); + + sections.forEach(s => { + s.style.opacity = '0'; + s.style.transform = 'translateY(20px)'; + s.style.transition = 'opacity 0.6s ease, transform 0.6s ease'; + observer.observe(s); + }); +}); +``` + +- [ ] **Step 7: Create gitkeep for static/img** + +```bash +mkdir -p static/img && touch static/img/.gitkeep +``` + +- [ ] **Step 8: Commit (no tests yet — wired up in Task 11)** + +```bash +git add app/templates/ static/ +git commit -m "feat(frontend): templates (base, index, descargas) + FIT-branded CSS + light JS" +``` + +--- + +## Task 11: Frontend routes (landing, register, descargas, download) + +**Files:** +- Create: `app/frontend.py` +- Modify: `app/main.py` +- Create: `tests/test_frontend.py` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/test_frontend.py +import os +from pathlib import Path + + +def test_landing_renders(client): + response = client.get("/") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + assert "Bootcamp Agentic AI" in response.text + assert 'name="email"' in response.text + assert 'name="website"' in response.text # honeypot present + + +def test_register_valid_form_redirects_with_token(client): + response = client.post( + "/register", + data={ + "nombre": "Felipe Arentsen", + "email": "felipe@factorit.com", + "empresa": "FactorIT", + "consentimiento": "on", + }, + follow_redirects=False, + ) + assert response.status_code == 303 + location = response.headers["location"] + assert location.startswith("/descargas?token=") + + +def test_register_missing_consent_fails(client): + response = client.post( + "/register", + data={ + "nombre": "Felipe", + "email": "f@x.com", + "empresa": "X", + }, + follow_redirects=False, + ) + assert response.status_code == 422 or response.status_code == 400 + + +def test_register_honeypot_filled_silently_drops(client): + response = client.post( + "/register", + data={ + "nombre": "Bot", + "email": "bot@spam.com", + "empresa": "Spam Inc", + "consentimiento": "on", + "website": "https://spam.com", + }, + follow_redirects=False, + ) + # Honeypot: return a believable redirect but DON'T persist + assert response.status_code == 303 + # Verify nothing was saved + from app.db import get_lead_by_email + assert get_lead_by_email("bot@spam.com") is None + + +def test_register_duplicate_email_reissues_token(client): + for _ in range(2): + client.post( + "/register", + data={ + "nombre": "Felipe", + "email": "dup@factorit.com", + "empresa": "FactorIT", + "consentimiento": "on", + }, + follow_redirects=False, + ) + from app.db import get_lead_by_email + lead = get_lead_by_email("dup@factorit.com") + assert lead["times_registered"] == 2 + + +def test_descargas_with_invalid_token_redirects_home(client): + response = client.get("/descargas?token=invalid-junk", follow_redirects=False) + assert response.status_code == 307 + assert response.headers["location"].startswith("/?error=") + + +def test_descargas_with_valid_token_renders(client): + reg = client.post( + "/register", + data={ + "nombre": "Maria", + "email": "maria@test.com", + "empresa": "Test Co", + "consentimiento": "on", + }, + follow_redirects=False, + ) + token = reg.headers["location"].split("token=")[1] + response = client.get(f"/descargas?token={token}") + assert response.status_code == 200 + assert "¡Hola Maria!" in response.text + assert "Material técnico" in response.text + assert "Material funcional" in response.text + + +def test_download_with_valid_token_serves_file(client, tmp_path): + # Put a fake zip in the configured material dir + material_dir = Path(os.environ["MATERIAL_DIR"]) + material_dir.mkdir(parents=True, exist_ok=True) + (material_dir / "taller-wox-tecnico.zip").write_bytes(b"PK\x03\x04 fake zip") + + reg = client.post( + "/register", + data={ + "nombre": "Test", + "email": "test-dl@test.com", + "empresa": "TC", + "consentimiento": "on", + }, + follow_redirects=False, + ) + token = reg.headers["location"].split("token=")[1] + response = client.get(f"/download/taller-wox-tecnico.zip?token={token}") + assert response.status_code == 200 + assert response.content == b"PK\x03\x04 fake zip" + assert "attachment" in response.headers["content-disposition"] + + +def test_download_with_invalid_filename_returns_404(client): + reg = client.post( + "/register", + data={ + "nombre": "Test", + "email": "bad-fn@test.com", + "empresa": "TC", + "consentimiento": "on", + }, + follow_redirects=False, + ) + token = reg.headers["location"].split("token=")[1] + response = client.get(f"/download/../../etc/passwd?token={token}") + assert response.status_code == 404 +``` + +- [ ] **Step 2: Run tests — expect 404s on all routes** + +```bash +pytest tests/test_frontend.py -v +``` + +Expected: failures because routes don't exist yet. + +- [ ] **Step 3: Implement `app/frontend.py`** + +```python +from pathlib import Path + +from fastapi import APIRouter, Form, HTTPException, Request +from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from itsdangerous import BadSignature, SignatureExpired +from pydantic import EmailStr + +from app.config import get_settings +from app.db import init_db, upsert_lead, log_download +from app.security import ( + create_download_token, + verify_download_token, + is_honeypot_filled, +) + +router = APIRouter() + +_TEMPLATES_DIR = Path(__file__).parent / "templates" +templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) + +# Whitelist of downloadable filenames — guards against path traversal +DOWNLOADS = [ + { + "filename": "taller-wox-tecnico.zip", + "icon": "🧩", + "title": "Material técnico", + "description": "Specs OpenAPI, configs y artefactos para importar a watsonx Orchestrate.", + }, + { + "filename": "taller-wox-funcional.zip", + "icon": "📚", + "title": "Material funcional", + "description": "Manual paso a paso del bootcamp y deck de slides.", + }, +] +ALLOWED_FILENAMES = {d["filename"] for d in DOWNLOADS} + + +# Ensure DB exists at startup (called from main.py) +def ensure_db_initialized() -> None: + init_db() + + +@router.get("/", response_class=HTMLResponse) +def landing(request: Request, error: str | None = None): + error_msg = None + if error == "token-invalido": + error_msg = "El link expiró o es inválido. Regístrate de nuevo para descargar el material." + return templates.TemplateResponse( + "index.html", + {"request": request, "error": error_msg}, + ) + + +@router.post("/register") +def register( + request: Request, + nombre: str = Form(..., min_length=2, max_length=80), + email: EmailStr = Form(...), + empresa: str = Form(..., min_length=2, max_length=100), + consentimiento: str = Form(...), # presence required + website: str | None = Form(default=""), # honeypot +): + if consentimiento != "on": + raise HTTPException(status_code=400, detail="Consentimiento requerido") + + # Honeypot filled → fake-success without persisting + if is_honeypot_filled(website): + fake_token = create_download_token(email="honeypot@discarded.local", nombre="x") + return RedirectResponse(url=f"/descargas?token={fake_token}", status_code=303) + + client_ip = request.client.host if request.client else None + user_agent = request.headers.get("user-agent") + + upsert_lead( + nombre=nombre, + email=str(email), + empresa=empresa, + ip=client_ip, + user_agent=user_agent, + consent=True, + ) + + token = create_download_token(email=str(email), nombre=nombre) + return RedirectResponse(url=f"/descargas?token={token}", status_code=303) + + +@router.get("/descargas", response_class=HTMLResponse) +def descargas(request: Request, token: str | None = None): + if not token: + return RedirectResponse(url="/?error=token-invalido", status_code=307) + try: + data = verify_download_token(token) + except (SignatureExpired, BadSignature): + return RedirectResponse(url="/?error=token-invalido", status_code=307) + + settings = get_settings() + material_dir = Path(settings.material_dir) + downloads_view = [] + for d in DOWNLOADS: + path = material_dir / d["filename"] + downloads_view.append({ + **d, + "available": path.exists(), + "size_mb": round(path.stat().st_size / (1024 * 1024), 1) if path.exists() else 0, + }) + + return templates.TemplateResponse( + "descargas.html", + { + "request": request, + "nombre": data.get("nombre", "amig@"), + "token": token, + "downloads": downloads_view, + }, + ) + + +@router.get("/download/{filename}") +def download(request: Request, filename: str, token: str | None = None): + if not token: + raise HTTPException(status_code=401, detail="Missing token") + try: + data = verify_download_token(token) + except (SignatureExpired, BadSignature): + raise HTTPException(status_code=401, detail="Invalid or expired token") + + if filename not in ALLOWED_FILENAMES: + raise HTTPException(status_code=404, detail="File not found") + + path = Path(get_settings().material_dir) / filename + if not path.exists(): + raise HTTPException(status_code=404, detail="File not available yet") + + client_ip = request.client.host if request.client else None + log_download(lead_email=data["email"], filename=filename, ip=client_ip) + + return FileResponse( + path=str(path), + filename=filename, + media_type="application/zip", + ) +``` + +- [ ] **Step 4: Wire static mount + frontend router + DB init into `app/main.py`** + +Replace `app/main.py` with: + +```python +from pathlib import Path + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + +from app import benefits_api, frontend, reports_api +from app.config import get_settings +from app.db import init_db + +settings = get_settings() + +app = FastAPI(title="taller-wox.fitlabs.dev", version="1.0.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=False, + allow_methods=["GET", "POST"], + allow_headers=["*"], +) + +# Static assets (CSS, JS, images) +_STATIC_DIR = Path(__file__).parent.parent / "static" +app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") + +# Routers +app.include_router(frontend.router) +app.include_router(benefits_api.router) +app.include_router(reports_api.router) + +# Generated reports served as static +_reports_dir = Path(settings.reports_output_dir) +_reports_dir.mkdir(parents=True, exist_ok=True) +app.mount( + "/api/reports/output", + StaticFiles(directory=str(_reports_dir)), + name="reports_output", +) + + +@app.on_event("startup") +def on_startup() -> None: + init_db() + + +@app.get("/health") +def health(): + return {"status": "ok", "base_url": settings.base_url} +``` + +- [ ] **Step 5: Run tests — expect pass** + +```bash +pytest tests/test_frontend.py -v +``` + +Expected: `9 passed`. + +- [ ] **Step 6: Full test suite check** + +```bash +pytest -v +``` + +Expected: all tests pass. + +- [ ] **Step 7: Manual smoke (browser)** + +```bash +uvicorn app.main:app --reload --port 8000 +``` + +Open `http://localhost:8000/`. Verify: +- Landing renders with FIT branding (navy hero, orange CTA) +- Form has all 4 fields + invisible honeypot +- Submit redirects to `/descargas?token=...` +- Descargas page shows 2 cards (both marked "No disponible" since material/ is empty in dev) +- Hitting `/descargas?token=garbage` redirects home with banner + +Stop the server. + +- [ ] **Step 8: Commit** + +```bash +git add app/frontend.py app/main.py tests/test_frontend.py +git commit -m "feat(frontend): landing, registration with honeypot, descargas with token, gated download" +``` + +--- + +## Task 12: Admin endpoints + +**Files:** +- Create: `app/admin.py` +- Modify: `app/main.py` +- Create: `tests/test_admin.py` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/test_admin.py +import base64 + + +def _auth_header(user: str = "testadmin", password: str = "testpass") -> dict: + token = base64.b64encode(f"{user}:{password}".encode()).decode() + return {"Authorization": f"Basic {token}"} + + +def test_admin_leads_requires_auth(client): + response = client.get("/admin/leads.json") + assert response.status_code == 401 + + +def test_admin_leads_with_bad_credentials_rejected(client): + response = client.get("/admin/leads.json", headers=_auth_header(password="wrong")) + assert response.status_code == 401 + + +def test_admin_leads_json_returns_list(client): + client.post( + "/register", + data={"nombre": "A", "email": "a@a.com", "empresa": "ACo", "consentimiento": "on"}, + ) + response = client.get("/admin/leads.json", headers=_auth_header()) + assert response.status_code == 200 + body = response.json() + assert isinstance(body, list) + assert any(l["email"] == "a@a.com" for l in body) + + +def test_admin_leads_csv_returns_csv(client): + client.post( + "/register", + data={"nombre": "B", "email": "b@b.com", "empresa": "BCo", "consentimiento": "on"}, + ) + response = client.get("/admin/leads.csv", headers=_auth_header()) + assert response.status_code == 200 + assert "text/csv" in response.headers["content-type"] + body = response.text + # UTF-8 BOM so Excel respects accents + assert body.startswith("") + assert "b@b.com" in body + assert "nombre,email,empresa" in body + + +def test_admin_stats_returns_counts(client): + client.post( + "/register", + data={"nombre": "C", "email": "c@c.com", "empresa": "CCo", "consentimiento": "on"}, + ) + response = client.get("/admin/stats", headers=_auth_header()) + assert response.status_code == 200 + body = response.json() + assert "total_leads" in body + assert "total_downloads" in body + assert "downloads_por_archivo" in body + assert "top_5_empresas" in body + assert body["total_leads"] >= 1 +``` + +- [ ] **Step 2: Run tests — expect 404** + +```bash +pytest tests/test_admin.py -v +``` + +Expected: all fail (no /admin routes yet). + +- [ ] **Step 3: Implement `app/admin.py`** + +```python +import csv +import io + +from fastapi import APIRouter, Depends, Query +from fastapi.responses import Response + +from app.db import list_leads, stats +from app.security import require_admin + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +@router.get("/leads.json") +def admin_leads_json( + _user: str = Depends(require_admin), + limit: int = Query(100, ge=1, le=1000), + offset: int = Query(0, ge=0), +): + return list_leads(limit=limit, offset=offset) + + +@router.get("/leads.csv") +def admin_leads_csv(_user: str = Depends(require_admin)): + rows = list_leads(limit=10_000, offset=0) + buf = io.StringIO() + if rows: + writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys())) + writer.writeheader() + writer.writerows(rows) + else: + buf.write("(no leads)\n") + # UTF-8 BOM so Excel respects accents + payload = "" + buf.getvalue() + return Response( + content=payload, + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": 'attachment; filename="leads.csv"'}, + ) + + +@router.get("/stats") +def admin_stats(_user: str = Depends(require_admin)): + return stats() +``` + +- [ ] **Step 4: Include the router in `app/main.py`** + +Add to imports: +```python +from app import admin +``` + +After the existing `app.include_router(reports_api.router)`: +```python +app.include_router(admin.router) +``` + +- [ ] **Step 5: Run tests — expect pass** + +```bash +pytest tests/test_admin.py -v +``` + +Expected: `5 passed`. + +- [ ] **Step 6: Commit** + +```bash +git add app/admin.py app/main.py tests/test_admin.py +git commit -m "feat(admin): leads.json, leads.csv, stats behind basic auth" +``` + +--- + +## Task 13: Dockerfile, README, gitkeeps + +**Files:** +- Create: `Dockerfile` +- Create: `README.md` +- Create: `material/.gitkeep` + +- [ ] **Step 1: Write `Dockerfile`** + +```dockerfile +FROM python:3.11-slim + +WORKDIR /app + +# System deps for pandas / plotly (minimal) +RUN apt-get update && apt-get install -y --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ +COPY static/ ./static/ + +# Runtime dirs — overlaid by Coolify persistent volumes in prod +RUN mkdir -p app/data/reports_output material + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +- [ ] **Step 2: Write `README.md`** + +```markdown +# taller-wox.fitlabs.dev + +Portal + API para el "Bootcamp Agentic AI con watsonx Orchestrate" de FactorIT. + +- **Landing público:** `/` +- **Registro + descargas:** `/register`, `/descargas?token=…`, `/download/{file}?token=…` +- **API del taller** (consumida por agentes de watsonx Orchestrate): + - `POST /api/historical-procedures` + - `POST /api/available-procedures` + - `GET /api/member-insights` + - `GET /api/schedule` + - `POST /api/reports/generate-report` +- **Admin** (HTTP Basic): `/admin/leads.json`, `/admin/leads.csv`, `/admin/stats` + +## Local dev + +```bash +python3.11 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +# edit .env if needed +uvicorn app.main:app --reload --port 8000 +``` + +Visit `http://localhost:8000/`. + +Run tests: +```bash +pytest -v +``` + +## Deploy en Coolify + +1. Crear nueva aplicación → tipo "Dockerfile" → conectar al repo. +2. Apuntar `taller-wox.fitlabs.dev` al servidor desde la UI; HTTPS automático con Let's Encrypt. +3. **Declarar volúmenes persistentes** (UI → Storage): + - `/app/leads.db` (bind file) + - `/app/app/data/reports_output` (bind dir) + - `/app/material` (bind dir) +4. **Configurar variables de entorno** (UI → Environment): + - `SECRET_KEY` (string aleatorio largo, ej. `python -c "import secrets; print(secrets.token_urlsafe(48))"`) + - `ADMIN_USER`, `ADMIN_PASS` + - `BASE_URL=https://taller-wox.fitlabs.dev` + - `TOKEN_EXPIRY_HOURS=24` +5. Subir los 2 ZIPs (`taller-wox-tecnico.zip`, `taller-wox-funcional.zip`) al volumen `/app/material` vía SFTP o el file manager de Coolify. **No requiere redeploy** — los archivos se sirven directo del volumen. +6. Deploy → Coolify buildea la imagen y arranca el container. + +### Workflow de actualizaciones +- **Código** → `git push` → Coolify rebuildea + redeploy. +- **ZIPs descargables** → subir directo al volumen `/app/material/` → sin redeploy. +- **Copy de las cards de descarga** → editar `app/frontend.py` (lista `DOWNLOADS`) → push + redeploy. + +## Spec y diseño +- Spec original: `SPEC_taller_wox_fitlabs.md` +- Diseño validado (autoridad): `docs/superpowers/specs/2026-05-12-taller-wox-design.md` +- Plan de implementación: `docs/superpowers/plans/2026-05-12-taller-wox.md` + +## Estructura + +``` +app/ + main.py FastAPI bootstrap + config.py env vars + db.py SQLite + security.py tokens + basic auth + frontend.py / · /register · /descargas · /download + benefits_api.py endpoints 1-4 + reports_api.py endpoint 5 + admin.py /admin/* + data/ datasets de los endpoints + templates/ jinja2 +static/ CSS, JS, imágenes +material/ ZIPs descargables (volumen persistente en prod) +tests/ pytest +Dockerfile +``` +``` + +- [ ] **Step 3: Create gitkeep for material/** + +```bash +mkdir -p material && touch material/.gitkeep +``` + +- [ ] **Step 4: Test that `docker build` works** + +```bash +docker build -t taller-wox . +``` + +Expected: clean build, no errors. (If you don't have Docker locally, skip this step — Coolify will catch any build issues.) + +- [ ] **Step 5: Smoke test in Docker locally (optional)** + +```bash +docker run --rm -p 8000:8000 \ + -e SECRET_KEY=local-dev-key \ + -e ADMIN_USER=admin \ + -e ADMIN_PASS=admin \ + -e BASE_URL=http://localhost:8000 \ + taller-wox +``` + +Visit `http://localhost:8000/health` — should return ok. Stop with Ctrl+C. + +- [ ] **Step 6: Commit** + +```bash +git add Dockerfile README.md material/ +git commit -m "feat(deploy): Dockerfile, README with Coolify instructions, material/ gitkeep" +``` + +--- + +## Task 14: End-to-end smoke check against SPEC §8 + +Before declaring done, run through the acceptance criteria from the design doc §9. This is a manual checklist task, not code. + +- [ ] **Step 1: Full test suite green** + +```bash +pytest -v +``` + +Expected: all tests pass. + +- [ ] **Step 2: Local server up** + +```bash +uvicorn app.main:app --reload --port 8000 +``` + +- [ ] **Step 3: Frontend smoke (curl + browser)** + +```bash +curl -s http://localhost:8000/ | head -30 +``` +Expected: HTML with "Bootcamp Agentic AI". + +```bash +curl -s -X POST http://localhost:8000/register \ + -d "nombre=Test&email=test@test.com&empresa=ACME&consentimiento=on" \ + -i | head -10 +``` +Expected: `HTTP/1.1 303 See Other` and `location: /descargas?token=...`. + +In a browser, open `http://localhost:8000/` and complete the form. Verify redirect to `/descargas` with personalized greeting and 2 cards. + +- [ ] **Step 4: API smoke** + +```bash +curl -s -X POST http://localhost:8000/api/historical-procedures \ + -H "Content-Type: application/json" \ + -d '{"filters":"[]","group_by":"[]"}' | python3 -m json.tool | head -20 + +curl -s -X POST http://localhost:8000/api/historical-procedures \ + -H "Content-Type: application/json" \ + -d '{"filters":"[{\"column\":\"member_name\",\"operator\":\"equals\",\"value\":\"Charlie Smith\"}]","group_by":"[]"}' | python3 -c "import json,sys; d=json.load(sys.stdin); print('Charlie rows:', len(d['result']))" + +curl -s http://localhost:8000/api/member-insights | python3 -c "import json,sys; d=json.load(sys.stdin); print('overdue:', len(d['result']['overdue_procedures']))" + +curl -s http://localhost:8000/api/schedule | python3 -m json.tool + +curl -s -X POST http://localhost:8000/api/reports/generate-report \ + -H "Content-Type: application/json" \ + -d '{"layout_config":"[\"care_report\"]"}' | python3 -m json.tool +``` + +Expected: +- historical_procedures: 50 records, then 5 Charlie rows +- member_insights: 4 overdue procedures +- schedule: text starting "Para agendar una cita médica" +- generate-report: `public_url` ending in `.html`. Open the URL in browser → verify chart + table render. + +- [ ] **Step 5: Admin smoke** + +```bash +curl -s -u admin:admin http://localhost:8000/admin/stats | python3 -m json.tool +curl -s -u admin:admin http://localhost:8000/admin/leads.csv | head -3 +``` + +Expected: stats JSON with counts, CSV with BOM + headers + the test lead from step 3. + +- [ ] **Step 6: Stop server** + +Ctrl+C. + +- [ ] **Step 7: Push to remote (Coolify auto-deploys)** + +```bash +git push origin master +``` + +Coolify watches the repo and rebuilds automatically. Verify in the Coolify UI that the deploy succeeded. + +- [ ] **Step 8: Production smoke** + +After Coolify reports the deploy is live: + +```bash +curl -sI https://taller-wox.fitlabs.dev/health +# Expect 200 + valid HTTPS (no -k flag needed) + +curl -s https://taller-wox.fitlabs.dev/api/member-insights | python3 -m json.tool | head -10 +# Expect the member object +``` + +- [ ] **Step 9: Upload the 2 ZIPs to the volume** + +Felipe: upload `taller-wox-tecnico.zip` and `taller-wox-funcional.zip` to the `/app/material/` volume via Coolify's file manager or SFTP. + +Verify in browser at `https://taller-wox.fitlabs.dev/` → register → confirm the 2 download cards now show real file sizes and downloads work. + +- [ ] **Step 10: Watsonx end-to-end (the real test)** + +Import `openapi-tools-spec.json` in a fresh watsonx Orchestrate agent. Run these prompts: + +- "¿Estoy atrasado en algún chequeo?" → must call `/api/member-insights`, return the 4 overdue procedures. +- "¿Cuánto cuesta una resonancia magnética?" → must call `/api/available-procedures` with a contains filter on `procedure`, return MRI results. +- "Create a care report" (with `openapi-tools-report.json` imported) → must call `/api/reports/generate-report` and return a URL the agent shows back. + +If all three work, you are done. + +- [ ] **Step 11: Final commit (notes from smoke testing, if any)** + +If you discovered any issues during smoke testing and fixed them, commit those fixes here. Otherwise, no commit needed. + +--- + +## Self-review (run after writing the plan) + +Confirmed during writing of this plan: + +**1. Spec coverage:** +- Design §2 Architecture → Tasks 1, 11 (main.py wiring) ✓ +- Design §3.1 Landing visual → Task 10 (templates) ✓ +- Design §3.2 Form with honeypot + duplicates → Tasks 4, 11 ✓ +- Design §3.3 Descargas with 2 cards → Tasks 10, 11 ✓ +- Design §3.4 Download with whitelist → Task 11 ✓ +- Design §3.5 The 5 API endpoints → Tasks 6, 7, 8, 9 ✓ +- Design §3.6 Admin → Task 12 ✓ +- Design §4.1 SQLite schema with UNIQUE email + times_registered → Task 3 ✓ +- Design §4.2 Persistent volumes → Task 13 README ✓ +- Design §5 Dockerfile + Coolify config → Task 13 ✓ +- Design §6 Synthetic datasets → Task 5 ✓ +- Design §9 Acceptance criteria → Task 14 ✓ + +**2. Placeholder scan:** No TBDs, no "implement later", no vague "handle edge cases" without specifics. All code blocks are complete and runnable. ✓ + +**3. Type consistency:** `upsert_lead`, `get_lead_by_email`, `log_download`, `list_leads`, `stats` are referenced consistently across tasks 3, 11, 12. `create_download_token`/`verify_download_token` referenced consistently across tasks 4 and 11. `require_admin` referenced consistently across tasks 4 and 12. ✓ + +--- + +## Execution Handoff + +**Plan complete and saved to `docs/superpowers/plans/2026-05-12-taller-wox.md`. Two execution options:** + +**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. Good for 48h timeline because tasks run in parallel where possible and you get a clean review checkpoint between each. + +**2. Inline Execution** — Execute tasks in this session using `superpowers:executing-plans`, batch execution with checkpoints for review. More predictable, single context. + +**Which approach?** diff --git a/docs/superpowers/specs/2026-05-12-taller-wox-design.md b/docs/superpowers/specs/2026-05-12-taller-wox-design.md new file mode 100644 index 0000000..2b80740 --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-taller-wox-design.md @@ -0,0 +1,460 @@ +# 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:** `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= +ADMIN_USER=felipe +ADMIN_PASS= +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. diff --git a/images/LogoFIT.png b/images/LogoFIT.png new file mode 100644 index 0000000..c5153ec Binary files /dev/null and b/images/LogoFIT.png differ diff --git a/material/.gitkeep b/material/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/material/LEEME.txt b/material/LEEME.txt new file mode 100644 index 0000000..dc1fcca --- /dev/null +++ b/material/LEEME.txt @@ -0,0 +1,91 @@ +============================================================== + BOOTCAMP AGENTIC AI — watsonx Orchestrate + Material de descarga del taller + taller-wox.fitlabs.dev +============================================================== + +Hola. Aquí está todo lo que vas a necesitar durante el taller. +Descarga TODOS los archivos en una carpeta llamada "bootcamp-fit" +en tu Escritorio antes de empezar. + + +---------------------------------------------------------------- +ARCHIVOS PRINCIPALES (los nombra el manual del alumno) +---------------------------------------------------------------- + + 1) openapi-tools-spec.json + OpenAPI principal de AskBenefits (4 endpoints de salud). + Lo importas en el módulo 2. + + 2) openapi-tools-spec.yaml + Misma especificación pero en formato YAML. + Úsalo solo si el .json te falla al subirlo. + + 3) dental_benefits_summary.pdf + PDF que sube al Knowledge Base de AskDental (módulo 3). + + 4) openapi-tools-report.json + OpenAPI del agente AskReporting (módulo 4). + + 5) main-desk-concierge-action.json + Acción de watsonx Assistant (módulo 5, opcional). + + 6) bank-assistant-pack.zip + YAMLs de los 6 agentes ADK + tools en Python + (módulo 5, demo del instructor). + + +---------------------------------------------------------------- +MATERIAL EXTRA (carpeta material-extra/) +---------------------------------------------------------------- + + - manual-completo-bootcamp.pdf + Manual oficial del bootcamp en PDF (referencia adicional, + 17 MB). Tiene todas las capturas originales del repositorio. + + - askbenefits-lab.pdf + Versión PDF del lab AskBenefits (en inglés). + + - deck-ibm-agents.pptx + Slides "Agents" de IBM, material teórico de soporte. + + - paper-react-yao-2022.pdf + Paper original de ReAct (Yao et al. 2022). + Lectura recomendada después del taller. + + - diagrama-arquitectura.png + Diagrama de arquitectura del caso AskBenefits. + + - RAG-Elastic.ipynb + qna.csv + Notebook y dataset para experimentar con RAG en + Elasticsearch (módulo 5, opcional). + + +---------------------------------------------------------------- +NOTAS IMPORTANTES +---------------------------------------------------------------- + +* Los textos de descripción de los agentes (los que copias + al Agent Builder) están en INGLÉS dentro del manual del + alumno. Esto es porque vienen del repositorio base. Si + quieres, puedes traducirlos después. + +* Los prompts de prueba en el manual del alumno están + en ESPAÑOL. + +* Si tu tool no responde y ves error de red, revisa con + el instructor — los endpoints corren en infraestructura + FIT Labs (taller-wox.fitlabs.dev/api). + + +---------------------------------------------------------------- +SOPORTE +---------------------------------------------------------------- + +Repositorio base original: + github.com/leozangulo/agentic-bootcamp + +Material del taller: + taller-wox.fitlabs.dev + +FactorIT · FIT diff --git a/material/dental_benefits_summary.pdf b/material/dental_benefits_summary.pdf new file mode 100644 index 0000000..a65def9 Binary files /dev/null and b/material/dental_benefits_summary.pdf differ diff --git a/material/main-desk-concierge-action.json b/material/main-desk-concierge-action.json new file mode 100644 index 0000000..87cccf6 --- /dev/null +++ b/material/main-desk-concierge-action.json @@ -0,0 +1,876 @@ +{ + "name": "Main Desk Concierge-action", + "type": "action", + "counts": { + "actions": 5, + "intents": 2, + "entities": 3, + "data_types": 0, + "collections": 0, + "global_variables": 0 + }, + "status": "Available", + "language": "en", + "skill_id": "1744ee3b-f227-45b2-9848-98415cc3c872", + "workspace": { + "actions": [ + { + "type": "standard", + "steps": [ + { + "step": "digression_failure", + "output": { + "generic": [ + { + "values": [ + { + "text": "Sorry I couldn't confirm if you wanted to return to previous topic, let me connect to an agent." + } + ], + "response_type": "text", + "selection_policy": "sequential" + } + ] + }, + "handlers": [], + "resolver": { + "type": "connect_to_agent", + "response": { + "transfer_info": { + "target": {} + }, + "agent_available": { + "message": "Let's send you to an available agent." + }, + "agent_unavailable": { + "message": "There are no agents available at this time. When one becomes available, we'll connect you." + }, + "message_to_human_agent": "" + } + }, + "variable": "digression_failure", + "condition": { + "eq": [ + { + "system_variable": "fallback_reason" + }, + { + "scalar": "Failed to confirm topic return" + } + ] + }, + "next_step": "step_001" + }, + { + "step": "step_001", + "output": { + "generic": [ + { + "values": [ + { + "text": "I'm afraid I don't understand. I can connect you to an agent." + } + ], + "response_type": "text", + "selection_policy": "sequential" + } + ] + }, + "handlers": [], + "resolver": { + "type": "connect_to_agent", + "response": { + "transfer_info": { + "target": {} + }, + "agent_available": { + "message": "Let's send you to an available agent." + }, + "agent_unavailable": { + "message": "There are no agents available at this time. When one becomes available, we'll connect you." + }, + "message_to_human_agent": "" + } + }, + "variable": "step_001", + "condition": { + "eq": [ + { + "system_variable": "fallback_reason" + }, + { + "scalar": "Step validation failed" + } + ] + }, + "next_step": "step_002" + }, + { + "step": "step_002", + "output": { + "generic": [ + { + "values": [ + { + "text": "Sorry I couldn't assist you. I will connect you to an agent right away." + } + ], + "response_type": "text", + "selection_policy": "sequential" + } + ] + }, + "handlers": [], + "resolver": { + "type": "connect_to_agent", + "response": { + "transfer_info": { + "target": {} + }, + "agent_available": { + "message": "Let's send you to an available agent." + }, + "agent_unavailable": { + "message": "There are no agents available at this time. When one becomes available, we'll connect you." + }, + "message_to_human_agent": "" + } + }, + "variable": "step_002", + "condition": { + "eq": [ + { + "system_variable": "fallback_reason" + }, + { + "scalar": "Agent requested" + } + ] + }, + "next_step": "step_003" + }, + { + "step": "step_003", + "output": { + "generic": [ + { + "values": [ + { + "text": "I am afraid I do not understand what you are asking, let me connect you to an agent." + } + ], + "response_type": "text", + "selection_policy": "sequential" + } + ] + }, + "handlers": [], + "resolver": { + "type": "connect_to_agent", + "response": { + "transfer_info": { + "target": {} + }, + "agent_available": { + "message": "Let's send you to an available agent." + }, + "agent_unavailable": { + "message": "There are no agents available at this time. When one becomes available, we'll connect you." + }, + "message_to_human_agent": "" + } + }, + "variable": "step_003", + "condition": { + "eq": [ + { + "system_variable": "fallback_reason" + }, + { + "scalar": "No action matches" + } + ] + }, + "next_step": "step_004" + }, + { + "step": "step_004", + "output": { + "generic": [ + { + "values": [ + { + "text": "It seems this conversation would be best managed by a human agent. Let me connect you to one of our agents." + } + ], + "response_type": "text", + "selection_policy": "sequential" + } + ] + }, + "handlers": [], + "resolver": { + "type": "connect_to_agent", + "response": { + "transfer_info": { + "target": {} + }, + "agent_available": { + "message": "Let's send you to an available agent." + }, + "agent_unavailable": { + "message": "There are no agents available at this time. When one becomes available, we'll connect you." + }, + "message_to_human_agent": "" + } + }, + "variable": "step_004", + "condition": { + "eq": [ + { + "system_variable": "fallback_reason" + }, + { + "scalar": "Danger word detected" + } + ] + }, + "next_step": "step_005" + }, + { + "step": "step_005", + "output": { + "generic": [ + { + "values": [ + { + "text": "It seems this conversation would be best managed by a human agent. Let me connect you to one of our agents." + } + ], + "response_type": "text", + "selection_policy": "sequential" + } + ] + }, + "handlers": [], + "resolver": { + "type": "connect_to_agent", + "response": { + "transfer_info": { + "target": {} + }, + "agent_available": { + "message": "Let's send you to an available agent." + }, + "agent_unavailable": { + "message": "There are no agents available at this time. When one becomes available, we'll connect you." + }, + "message_to_human_agent": "" + } + }, + "variable": "step_005", + "condition": { + "eq": [ + { + "system_variable": "fallback_reason" + }, + { + "scalar": "Profanity detected" + } + ] + } + } + ], + "title": "Fallback", + "action": "fallback", + "boosts": [], + "handlers": [], + "condition": { + "intent": "fallback_connect_to_agent" + }, + "variables": [ + { + "title": "Topic return failed", + "variable": "digression_failure", + "data_type": "any" + }, + { + "title": "I'm afraid I don't understand. I can connect you to an agent.", + "variable": "step_001", + "data_type": "any" + }, + { + "title": "Sorry I couldn't assist you. I will connect you to an agent righ", + "variable": "step_002", + "data_type": "any" + }, + { + "title": "I am afraid I do not understand what you are asking, let me conn", + "variable": "step_003", + "data_type": "any" + }, + { + "title": "It seems this conversation would be best managed", + "variable": "step_004", + "data_type": "any" + }, + { + "title": "Profanity - It seems this conversation", + "variable": "step_005", + "data_type": "any" + } + ], + "next_action": "run_always", + "disambiguation_opt_out": true + }, + { + "type": "standard", + "steps": [ + { + "step": "danger_word_detected", + "title": "Connect to agent", + "handlers": [], + "resolver": { + "type": "fallback" + }, + "variable": "danger_word_detected_variable", + "condition": { + "entity": "danger_words" + }, + "next_step": "profanity_detected" + }, + { + "step": "profanity_detected", + "title": "Show warning", + "output": { + "generic": [ + { + "values": [ + { + "text_expression": { + "concat": [ + { + "scalar": "Please use appropriate language when interacting with the assistant." + } + ] + } + } + ], + "response_type": "text", + "selection_policy": "sequential" + } + ] + }, + "handlers": [ + { + "type": "max_hits", + "handler": "max_hits_handler", + "resolver": { + "type": "fallback" + } + } + ], + "max_hits": 2, + "resolver": { + "type": "end_action" + }, + "variable": "profanity_detected_variable", + "condition": { + "entity": "profane_words" + } + } + ], + "title": "Trigger word detected", + "action": "run_always", + "boosts": [], + "handlers": [], + "variables": [ + { + "title": "Profanity detected", + "variable": "danger_word_detected_variable", + "data_type": "any" + }, + { + "title": "Profane word detected", + "variable": "profanity_detected_variable", + "data_type": "any" + } + ], + "next_action": "anything_else" + }, + { + "type": "standard", + "steps": [ + { + "step": "step_001", + "output": { + "generic": [ + { + "values": [ + { + "text": "I'm afraid I don't understand. Please rephrase your question." + } + ], + "response_type": "text", + "selection_policy": "sequential" + } + ] + }, + "handlers": [], + "resolver": { + "type": "end_action" + }, + "variable": "step_001", + "condition": { + "lte": [ + { + "system_variable": "no_action_matches_count" + }, + { + "scalar": 3 + } + ] + }, + "next_step": "step_002" + }, + { + "step": "step_002", + "output": { + "generic": [ + { + "values": [ + { + "text": "" + } + ], + "response_type": "text", + "selection_policy": "sequential" + } + ] + }, + "handlers": [], + "resolver": { + "type": "invoke_another_action_and_end", + "invoke_action": { + "action": "fallback", + "policy": "default", + "parameters": null, + "result_variable": "step_002_result_1" + } + }, + "variable": "step_002", + "condition": { + "gt": [ + { + "system_variable": "no_action_matches_count" + }, + { + "scalar": 3 + } + ] + } + } + ], + "title": "No matches", + "action": "anything_else", + "boosts": [], + "handlers": [], + "condition": { + "expression": "anything_else" + }, + "variables": [ + { + "title": "I am afraid I do not understand what you are asking, please re-p", + "variable": "step_001", + "data_type": "any" + }, + { + "variable": "step_002", + "data_type": "any" + }, + { + "variable": "step_002_result_1", + "data_type": "any" + } + ], + "disambiguation_opt_out": true + }, + { + "type": "standard", + "steps": [ + { + "step": "step_001", + "output": { + "generic": [ + { + "values": [ + { + "text": "Welcome, how can I assist you?" + } + ], + "response_type": "text", + "selection_policy": "sequential" + } + ] + }, + "handlers": [], + "resolver": { + "type": "end_action" + }, + "variable": "step_001" + } + ], + "title": "Greet customer", + "action": "welcome", + "boosts": [], + "handlers": [], + "condition": { + "expression": "welcome" + }, + "variables": [ + { + "variable": "step_001", + "data_type": "any" + } + ], + "next_action": "action_44900", + "disambiguation_opt_out": true + }, + { + "type": "standard", + "steps": [ + { + "step": "step_356", + "title": "Get Patients Name", + "output": { + "generic": [ + { + "values": [ + { + "text_expression": { + "concat": [ + { + "scalar": "Sure I can help you with that! What's your first and last name?" + } + ] + } + } + ], + "response_type": "text", + "selection_policy": "sequential" + } + ] + }, + "handlers": [], + "question": { + "free_text": true + }, + "resolver": { + "type": "continue" + }, + "variable": "step_356", + "next_step": "step_572" + }, + { + "step": "step_572", + "title": "Get Patients DOB", + "output": { + "generic": [ + { + "values": [ + { + "text_expression": { + "concat": [ + { + "scalar": "Thank you! Could you please confirm your date of birth for me?" + } + ] + } + } + ], + "response_type": "text", + "selection_policy": "sequential" + } + ] + }, + "handlers": [], + "question": { + "free_text": true + }, + "resolver": { + "type": "continue" + }, + "variable": "step_572", + "next_step": "step_309" + }, + { + "step": "step_309", + "title": "Conclusion", + "output": { + "generic": [ + { + "values": [ + { + "text_expression": { + "concat": [ + { + "scalar": "Great I have all the information I need, and I am now ready to answer your questions!" + } + ] + } + } + ], + "response_type": "text", + "selection_policy": "sequential" + } + ] + }, + "handlers": [], + "resolver": { + "type": "end_action" + }, + "variable": "step_309" + } + ], + "title": "Main Desk", + "action": "action_44900", + "boosts": [], + "handlers": [], + "condition": { + "intent": "action_44900_intent_12533" + }, + "variables": [ + { + "title": "Conclusion", + "privacy": { + "enabled": false + }, + "variable": "step_309", + "data_type": "any" + }, + { + "title": "Get Patients Name", + "privacy": { + "enabled": false + }, + "variable": "step_356", + "data_type": "any" + }, + { + "title": "Get Patients DOB", + "privacy": { + "enabled": false + }, + "variable": "step_572", + "data_type": "any" + } + ], + "launch_mode": "learning", + "next_action": "fallback", + "topic_switch": { + "allowed_from": true, + "allowed_into": true, + "never_return": false + }, + "disambiguation_opt_out": false + } + ], + "intents": [ + { + "intent": "fallback_connect_to_agent", + "examples": [ + { + "text": "Call agent" + }, + { + "text": "Agent help" + }, + { + "text": "I would like to speak to a human" + }, + { + "text": "Can I connect to an agent?" + }, + { + "text": "I would like to speak to someone" + } + ], + "description": "Please transfer me to an agent" + }, + { + "intent": "action_44900_intent_12533", + "examples": [ + { + "text": "I want to access my medical records" + } + ] + } + ], + "entities": [ + { + "entity": "sys-yes-no", + "values": [] + }, + { + "entity": "danger_words", + "values": [], + "fuzzy_match": false + }, + { + "entity": "profane_words", + "values": [], + "fuzzy_match": false + } + ], + "metadata": { + "api_version": { + "major_version": "v2", + "minor_version": "2018-11-08" + }, + "skill": { + "counts": { + "actions": 5, + "intents": 2, + "entities": 3, + "data_types": 0, + "collections": 0, + "global_variables": 0 + } + } + }, + "variables": [], + "data_types": [], + "collections": [], + "counterexamples": [], + "system_settings": { + "variable": { + "format": { + "time": { + "pattern": "short" + }, + "currency": { + "fraction_digits": 2 + } + } + }, + "off_topic": { + "enabled": true + }, + "auto_learn": { + "apply": true + }, + "topic_switch": { + "enabled": true, + "messages": { + "enable_confirmation": true, + "confirmation_failure": { + "generic": [ + { + "values": [ + { + "text_expression": { + "concat": [ + { + "scalar": "I'm sorry, I did not catch that, please confirm." + } + ] + } + } + ], + "response_type": "text", + "selection_policy": "sequential" + } + ] + }, + "confirmation_request": { + "generic": [ + { + "values": [ + { + "text_expression": { + "concat": [ + { + "scalar": "Do you want to continue with the previous topic: " + }, + { + "system_variable": "digressed_from" + }, + { + "scalar": "?" + } + ] + } + } + ], + "response_type": "text", + "selection_policy": "sequential" + }, + { + "options": [ + { + "label": "Yes", + "value": { + "input": { + "text": "Yes" + } + } + }, + { + "label": "No", + "value": { + "input": { + "text": "No" + } + } + } + ], + "response_type": "option", + "repeat_on_reprompt": true + } + ] + } + }, + "max_tries": 3 + }, + "generative_ai": { + "model_id": "ibm/granite-3-8b-instruct", + "hap_filter": { + "output": { + "enabled": true + } + } + }, + "disambiguation": { + "prompt": "Did you mean:", + "enabled": true, + "randomize": true, + "max_suggestions": 5, + "suggestion_text_policy": "title", + "none_of_the_above_prompt": "None of the above", + "use_connect_to_support_prompt": "Connect to support", + "single_answer_clarification_prompt": "Something else" + }, + "search_routing": { + "target": "conversational_search" + }, + "spelling_auto_correct": true + }, + "learning_opt_out": true, + "language": "en" + }, + "description": "created for assistant dfcdc7a1-4a37-4714-9189-daa085b3e4a9", + "dialog_settings": { + "source_assistant": "14184e97-fe24-4fc4-ae2a-addfb22e41c1" + }, + "created": "2025-05-16T15:38:23.103Z", + "updated": "2025-05-16T15:38:23.103Z", + "snapshot": "1", + "assistant_id": "dfcdc7a1-4a37-4714-9189-daa085b3e4a9", + "assistant_references": [ + { + "name": "Main Desk Concierge", + "assistant_id": "14184e97-fe24-4fc4-ae2a-addfb22e41c1", + "skill_reference": "actions skill" + } + ] +} \ No newline at end of file diff --git a/material/material-extra/RAG-Elastic.ipynb b/material/material-extra/RAG-Elastic.ipynb new file mode 100644 index 0000000..e212ea0 --- /dev/null +++ b/material/material-extra/RAG-Elastic.ipynb @@ -0,0 +1,624 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "292fbf42", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/leozangulo/Library/Python/3.9/lib/python/site-packages/urllib3/__init__.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020\n", + " warnings.warn(\n" + ] + } + ], + "source": [ + "from elasticsearch import Elasticsearch\n", + "from elasticsearch.helpers import bulk\n", + "from elasticsearch import exceptions as es_exceptions\n", + "from langchain.embeddings import SentenceTransformerEmbeddings\n", + "import pandas as pd\n", + "import os" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "84e039f6", + "metadata": {}, + "outputs": [], + "source": [ + "try:\n", + " esuser = \"\"\n", + "except KeyError:\n", + " esuser = input(\"Please enter your Elasticsearch user name (hit enter): \")\n", + "try:\n", + " espassword = \"\"\n", + "except KeyError:\n", + " espassword = getpass.getpass(\"Please enter your Elasticsearch password (hit enter): \")\n", + "try:\n", + " eshost = \"\"\n", + "except KeyError:\n", + " eshost = input(\"Please enter your Elasticsearch hostname (hit enter): \")\n", + "try:\n", + " esport = \"XXXX\"\n", + "except KeyError:\n", + " esport = input(\"Please enter your Elasticsearch port number (hit enter): \")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "cb563197", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'22:B8:75:5A:05:3F:6D:7D:D7:43:CB:07:63:41:0B:5B:B0:AC:2F:C9:9F:BF:8C:CE:3C:D4:42:5F:B0:92:E4:A9'" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "es_ssl_fingerprint = !openssl s_client -connect $eshost:$esport -showcerts /dev/null | openssl x509 -fingerprint -sha256 -noout -in /dev/stdin\n", + "es_ssl_fingerprint = es_ssl_fingerprint[0].split(\"=\")[1]\n", + "es_ssl_fingerprint" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "aa36655c", + "metadata": {}, + "outputs": [], + "source": [ + "elastic_client = Elasticsearch([f\"https://{esuser}:{espassword}@{eshost}:{esport}\"],\n", + " basic_auth=(esuser, espassword),\n", + " request_timeout=None,\n", + " ssl_assert_fingerprint=es_ssl_fingerprint)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "88d064ea-3ec8-49e4-b0ca-1c8c77b7def7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Conexión exitosa. Info del clúster:\n", + "{'name': 'm-1.63ae1f09-63d0-42c1-a834-11658d84d119.b3580ea2cd7943f795db3d4b2f063fc7.c5kmhkid0ujpmrucb800.databases.appdomain.cloud', 'cluster_name': '63ae1f09-63d0-42c1-a834-11658d84d119', 'cluster_uuid': '1OewP8bXSO2Y48FwiRaqeg', 'version': {'number': '8.15.0', 'build_flavor': 'default', 'build_type': 'tar', 'build_hash': '1a77947f34deddb41af25e6f0ddb8e830159c179', 'build_date': '2024-08-05T10:05:34.233336849Z', 'build_snapshot': False, 'lucene_version': '9.11.1', 'minimum_wire_compatibility_version': '7.17.0', 'minimum_index_compatibility_version': '7.0.0'}, 'tagline': 'You Know, for Search'}\n" + ] + } + ], + "source": [ + "# Verificar que la conexión este creada\n", + "try:\n", + " info = elastic_client.info()\n", + " print(\"Conexión exitosa. Info del clúster:\")\n", + " print(info)\n", + "except es_exceptions.AuthenticationException as e:\n", + " print(\"Error de autenticación:\", e.info)\n", + "except es_exceptions.ConnectionError as e:\n", + " print(\"Error de conexión:\", e.info)\n", + "except es_exceptions.AuthorizationException as e:\n", + " print(\"Error de autorización:\", e.info)\n", + "except es_exceptions.TransportError as e:\n", + " print(\"Error general de transporte:\", e.info)\n", + "except Exception as e:\n", + " print(\"Otro error:\", str(e))" + ] + }, + { + "cell_type": "markdown", + "id": "f856c004-8571-4e83-9a2f-1366c1f5b021", + "metadata": {}, + "source": [ + "## Selección del modelo" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "2cf5eb6c", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/var/folders/h_/w76hwqrd6kv3q8ps8cm4_y980000gn/T/ipykernel_87367/2620494856.py:1: LangChainDeprecationWarning: The class `HuggingFaceEmbeddings` was deprecated in LangChain 0.2.2 and will be removed in 1.0. An updated version of the class exists in the :class:`~langchain-huggingface package and should be used instead. To use it run `pip install -U :class:`~langchain-huggingface` and import as `from :class:`~langchain_huggingface import HuggingFaceEmbeddings``.\n", + " emb_func = SentenceTransformerEmbeddings(model_name=\"all-MiniLM-L6-v2\")\n", + "/Users/leozangulo/Library/Python/3.9/lib/python/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", + " from .autonotebook import tqdm as notebook_tqdm\n" + ] + } + ], + "source": [ + "emb_func = SentenceTransformerEmbeddings(model_name=\"all-MiniLM-L6-v2\")\n", + "dims = emb_func.client.get_sentence_embedding_dimension()" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "7fdf69ae", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "ObjectApiResponse({'acknowledged': True, 'shards_acknowledged': True, 'index': 'test_nds_v2'})" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Creación del índice y del esquema\n", + "index_name = \"test_nds_v2\"\n", + "\n", + "mapping = {\n", + " \"properties\": {\n", + " \"text\": {\n", + " \"type\": \"text\"\n", + " },\n", + " \"embedding\": {\n", + " \"type\": \"dense_vector\",\n", + " \"dims\": dims,\n", + " \"index\": True,\n", + " \"similarity\": \"l2_norm\"\n", + " }\n", + " }\n", + " }\n", + "if elastic_client.indices.exists(index=index_name):\n", + " elastic_client.indices.delete(index=index_name)\n", + " \n", + "elastic_client.indices.create(index=index_name, mappings=mapping)\n" + ] + }, + { + "cell_type": "markdown", + "id": "0b58d667", + "metadata": {}, + "source": [ + "## Definición de documentos" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "e498f6cf", + "metadata": {}, + "outputs": [], + "source": [ + "# Seleccionar todos los .txt\n", + "scl = []\n", + "for file in os.listdir(\"./\"):\n", + " if file.endswith(\".txt\"):\n", + " print(os.path.join(file))\n", + " scl.append(file)" + ] + }, + { + "cell_type": "code", + "execution_count": 45, + "id": "a78ea9a7-29f3-4094-a5f3-feb9035ece14", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
resumenCompositorPreguntaRespuesta
0Tema: Mozart, Pregunta: ¿Dónde nació Mozart?, ...Mozart¿Dónde nació Mozart?Mozart nació en Salzburgo, que en su época era...
1Tema: Mozart, Pregunta: ¿A qué edad empezó a c...Mozart¿A qué edad empezó a componer?Comenzó a componer desde los 5 años, escribien...
2Tema: Mozart, Pregunta: ¿Cuál es su nombre com...Mozart¿Cuál es su nombre completo?Su nombre completo era Johannes Chrysostomus W...
3Tema: Mozart, Pregunta: ¿Qué significa “Amadeu...Mozart¿Qué significa “Amadeus”?Significa 'amado de Dios' en latín, aunque tam...
4Tema: Mozart, Pregunta: ¿Qué famosa ópera comp...Mozart¿Qué famosa ópera compuso en 1786?Compuso 'Las bodas de Fígaro' (Le nozze di Fig...
5Tema: Mozart, Pregunta: ¿Cuál es su ópera cons...Mozart¿Cuál es su ópera considerada la más oscura y ...Sin duda 'Don Giovanni', que mezcla comedia y ...
6Tema: Mozart, Pregunta: ¿Qué obra dejó inconcl...Mozart¿Qué obra dejó inconclusa al morir?Su famoso Réquiem en Re menor, que estaba comp...
7Tema: Mozart, Pregunta: ¿Quién terminó su Réqu...Mozart¿Quién terminó su Réquiem?Fue su alumno Franz Xaver Süssmayr, quien comp...
8Tema: Mozart, Pregunta: ¿En qué ciudad murió M...Mozart¿En qué ciudad murió Mozart?Murió en Viena, la capital de Austria, donde p...
9Tema: Mozart, Pregunta: ¿A qué edad murió?, Re...Mozart¿A qué edad murió?Murió muy joven, a los 35 años, dejando más de...
\n", + "
" + ], + "text/plain": [ + " resumen Compositor \\\n", + "0 Tema: Mozart, Pregunta: ¿Dónde nació Mozart?, ... Mozart \n", + "1 Tema: Mozart, Pregunta: ¿A qué edad empezó a c... Mozart \n", + "2 Tema: Mozart, Pregunta: ¿Cuál es su nombre com... Mozart \n", + "3 Tema: Mozart, Pregunta: ¿Qué significa “Amadeu... Mozart \n", + "4 Tema: Mozart, Pregunta: ¿Qué famosa ópera comp... Mozart \n", + "5 Tema: Mozart, Pregunta: ¿Cuál es su ópera cons... Mozart \n", + "6 Tema: Mozart, Pregunta: ¿Qué obra dejó inconcl... Mozart \n", + "7 Tema: Mozart, Pregunta: ¿Quién terminó su Réqu... Mozart \n", + "8 Tema: Mozart, Pregunta: ¿En qué ciudad murió M... Mozart \n", + "9 Tema: Mozart, Pregunta: ¿A qué edad murió?, Re... Mozart \n", + "\n", + " Pregunta \\\n", + "0 ¿Dónde nació Mozart? \n", + "1 ¿A qué edad empezó a componer? \n", + "2 ¿Cuál es su nombre completo? \n", + "3 ¿Qué significa “Amadeus”? \n", + "4 ¿Qué famosa ópera compuso en 1786? \n", + "5 ¿Cuál es su ópera considerada la más oscura y ... \n", + "6 ¿Qué obra dejó inconclusa al morir? \n", + "7 ¿Quién terminó su Réquiem? \n", + "8 ¿En qué ciudad murió Mozart? \n", + "9 ¿A qué edad murió? \n", + "\n", + " Respuesta \n", + "0 Mozart nació en Salzburgo, que en su época era... \n", + "1 Comenzó a componer desde los 5 años, escribien... \n", + "2 Su nombre completo era Johannes Chrysostomus W... \n", + "3 Significa 'amado de Dios' en latín, aunque tam... \n", + "4 Compuso 'Las bodas de Fígaro' (Le nozze di Fig... \n", + "5 Sin duda 'Don Giovanni', que mezcla comedia y ... \n", + "6 Su famoso Réquiem en Re menor, que estaba comp... \n", + "7 Fue su alumno Franz Xaver Süssmayr, quien comp... \n", + "8 Murió en Viena, la capital de Austria, donde p... \n", + "9 Murió muy joven, a los 35 años, dejando más de... " + ] + }, + "execution_count": 45, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "documents_filename = \"./qna.csv\"\n", + "documents = pd.read_csv(documents_filename, header=0)\n", + "c = documents['Compositor']\n", + "p = documents['Pregunta']\n", + "r = documents['Respuesta']\n", + "documents['resumen'] = 'Tema: '+c+', Pregunta: '+p+', Respuesta: '+r\n", + "\n", + "\n", + "cols = documents.columns.tolist()\n", + "cols = ['resumen'] + [col for col in cols if col != 'resumen']\n", + "documents = documents[cols]\n", + "\n", + "documents[:10]" + ] + }, + { + "cell_type": "code", + "execution_count": 46, + "id": "6e1c4696", + "metadata": {}, + "outputs": [], + "source": [ + "texts = documents.resumen.tolist()\n", + "embedded_docs = emb_func.embed_documents(texts)" + ] + }, + { + "cell_type": "code", + "execution_count": 58, + "id": "2f52cc78", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "test_nds_v2\n" + ] + }, + { + "data": { + "text/plain": [ + "ObjectApiResponse({'_shards': {'total': 2, 'successful': 2, 'failed': 0}})" + ] + }, + "execution_count": 58, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Guardar los documentos en la DB\n", + "for i, (text, vector) in enumerate(zip(texts, embedded_docs)):\n", + " document_l = {\"embedding\": vector, 'text': text}\n", + " elastic_client.index(index=index_name, document=document_l)\n", + " \n", + "\n", + "elastic_client.indices.refresh(index=index_name)" + ] + }, + { + "cell_type": "code", + "execution_count": 60, + "id": "7110e733-5e33-4a07-908b-790a9a67ad25", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "📊 El índice '.ent-search-actastic-oauth_access_tokens_v2' tiene 50 documentos.\n" + ] + } + ], + "source": [ + "# Mostrar cantidad de indices\n", + "try:\n", + " response = elastic_client.count(index=\"test_nds_v2\")\n", + " total_docs = response['count']\n", + " print(f\"📊 El índice '{indice}' tiene {total_docs} documentos.\")\n", + "except Exception as e:\n", + " print(\"❌ Error al obtener el conteo:\", e)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1a460246-f99d-46f4-bdf1-d8dd7a20ddfd", + "metadata": {}, + "outputs": [], + "source": [ + "#document_list" + ] + }, + { + "cell_type": "code", + "execution_count": 54, + "id": "9332a307-e2f6-4d7d-a094-38547e85373b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "📊 El índice '.ent-search-actastic-oauth_access_tokens_v2' tiene 0 documentos.\n" + ] + } + ], + "source": [ + "try:\n", + " response = elastic_client.count(index=\"test_nds_v2\")\n", + " total_docs = response['count']\n", + " print(f\"📊 El índice '{indice}' tiene {total_docs} documentos.\")\n", + "except Exception as e:\n", + " print(\"❌ Error al obtener el conteo:\", e)" + ] + }, + { + "cell_type": "code", + "execution_count": 63, + "id": "19a46065-a48b-425e-8a89-cb0089748220", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "- .ent-search-actastic-workplace_search_accounts_v16\n", + "- .ent-search-actastic-workplace_search_search_groups_v4-name-unique-constraint\n", + "- .ent-search-actastic-crawler2_robots_txts\n", + "- .ent-search-actastic-workplace_search_pre_content_sources_v3\n", + "- .ent-search-actastic-crawler_crawl_requests_v7\n", + "- .ent-search-esqueues-me_queue_v1_process_crawl2\n", + "- .ent-search-actastic-reindex_jobs_v3\n", + "- .ent-search-actastic-workplace_search_role_mappings_v8\n", + "- .kibana_8.15.0_001\n", + "- .ent-search-actastic-search_relevance_suggestion_update_process_v1\n", + "- .apm-custom-link\n", + "- .ent-search-actastic-connectors_jobs_v5\n", + "- .ml-annotations-000001\n", + "- .ent-search-actastic-workplace_search_content_sources_v23\n", + "- .internal.alerts-observability.uptime.alerts-default-000001\n", + "- .ent-search-actastic-users_v7-auth_source-elasticsearch_username-unique-constraint\n", + "- .ent-search-actastic-crawler_process_crawls\n", + "- .apm-source-map\n", + "- .ent-search-actastic-users_v7-email-unique-constraint\n", + "- .ent-search-actastic-crawler2_configurations_v2-index_name-unique-constraint\n", + "- .slo-observability.summary-v3.3.temp\n", + "- .ent-search-actastic-app_search_api_tokens_v3-authentication_token-unique-constraint\n", + "- .ent-search-actastic-workplace_search_organizations_v17\n", + "- .ent-search-actastic-search_indices_v1\n", + "- .ent-search-actastic-app_search_crawler_content_url_metadata\n", + "- .elastic-connectors-sync-jobs-v1\n", + "- .ent-search-esqueues-me_queue_v1_process_crawl\n", + "- .kibana_task_manager_8.15.0_001\n", + "- .ent-search-actastic-crawler2_content_metadata-configuration_oid-content_hash-unique-constraint\n", + "- .ent-search-esqueues-me_worker_v1\n", + "- .ent-search-actastic-engines_v26\n", + "- .ml-notifications-000002\n", + "- .internal.alerts-transform.health.alerts-default-000001\n", + "- .ent-search-actastic-app_search_api_token_engines\n", + "- .internal.alerts-observability.apm.alerts-default-000001\n", + "- .ent-search-actastic-crawler2_domains-configuration_oid-name-unique-constraint\n", + "- .ent-search-actastic-crawler2_configurations_v2\n", + "- .ent-search-actastic-crawler2_content_metadata\n", + "- .ent-search-esqueues-me_queue_v1_mailer\n", + "- .kibana_security_solution_8.15.0_001\n", + "- .internal.alerts-security.alerts-default-000001\n", + "- .ent-search-actastic-workplace_search_pre_content_sources_v3-context-workplace_search_account_id-service_type-unique-constraint\n", + "- .ent-search-actastic-secret_keeper_secrets\n", + "- .ent-search-actastic-crawler_domains_v6\n", + "- .internal.alerts-observability.logs.alerts-default-000001\n", + "- .ent-search-actastic-oauth_access_tokens_v2-token-unique-constraint\n", + "- .ent-search-actastic-crawler2_process_crawls\n", + "- .ent-search-actastic-crawler2_content_url_metadata\n", + "- .ent-search-actastic-app_search_role_mapping_engines_v4\n", + "- .ent-search-actastic-oauth_applications_v2\n", + "- .ent-search-actastic-oauth_applications_v2-uid-unique-constraint\n", + "- .ent-search-actastic-search_relevance_suggestions-document_position_id-unique-constraint\n", + "- .kibana-observability-ai-assistant-kb-000001\n", + "- .ent-search-esqueues-me_queue_v1_engine_destroyer\n", + "- .ent-search-actastic-app_search_crawler_content_metadata-content_hash-engine_oid-unique-constraint\n", + "- .ent-search-actastic-app_search_document_positions_v3\n", + "- .ent-search-db-lock-20200304\n", + "- .ent-search-actastic-oauth_access_grants_v2\n", + "- .kibana_alerting_cases_8.15.0_001\n", + "- .slo-observability.sli-v3.3\n", + "- .internal.alerts-ml.anomaly-detection.alerts-default-000001\n", + "- .ent-search-actastic-synonyms\n", + "- .kibana_security_session_1\n", + "- .internal.alerts-observability.slo.alerts-default-000001\n", + "- .ent-search-actastic-crawler_domains_v6-engine_oid-name-unique-constraint\n", + "- .ent-search-actastic-workplace_search_content_source_user_identities_v4\n", + "- .internal.alerts-observability.metrics.alerts-default-000001\n", + "- .kibana_ingest_8.15.0_001\n", + "- .ent-search-actastic-workplace_search_api_tokens\n", + "- .ent-search-actastic-telemetry_status_v3\n", + "- .internal.alerts-stack.alerts-default-000001\n", + "- .ent-search-actastic-togo_migrations_v1\n", + "- .apm-agent-configuration\n", + "- .ent-search-actastic-app_search_crawler_content_metadata\n", + "- .kibana-observability-ai-assistant-conversations-000001\n", + "- .ent-search-actastic-crawler2_domains\n", + "- .ent-search-actastic-search_relevance_suggestions\n", + "- .internal.alerts-observability.threshold.alerts-default-000001\n", + "- .ent-search-actastic-users_v7\n", + "- .ent-search-actastic-workplace_search_search_groups_v4\n", + "- .ent-search-actastic-app_search_invitations_v3\n", + "- .ent-search-actastic-engines_v26-loco_moco_account_id-slug-unique-constraint\n", + "- .internal.alerts-default.alerts-default-000001\n", + "- .internal.alerts-ml.anomaly-detection-health.alerts-default-000001\n", + "- .ent-search-actastic-crawler2_crawl_requests_v2\n", + "- .ent-search-actastic-app_search_document_position_queries_v4\n", + "- .ent-search-actastic-oauth_access_tokens_v2-refresh_token-unique-constraint\n", + "- .ent-search-actastic-crawler2_extraction_rules\n", + "- .ent-search-actastic-app_search_api_tokens_v3\n", + "- .slo-observability.summary-v3.3\n", + "- .ent-search-actastic-workplace_search_invitations_v3\n", + "- .kibana_analytics_8.15.0_001\n", + "- .ent-search-actastic-workplace_search_accounts_v16-user_oid-unique-constraint\n", + "- .ent-search-actastic-crawler_robots_txts_v3\n", + "- test_nds_v2\n", + "- test_nds\n", + "- .ent-search-actastic-app_search_accounts_v11\n", + "- .ent-search-actastic-app_search_role_mappings_v5\n", + "- .ent-search-actastic-oauth_access_tokens_v2\n" + ] + } + ], + "source": [ + "# Ver cuantos indices hay\n", + "indices = elastic_client.indices.get_alias(index=\"*\").keys()\n", + "for indice in indices:\n", + " print(\"-\", indice)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/material/material-extra/askbenefits-lab.pdf b/material/material-extra/askbenefits-lab.pdf new file mode 100644 index 0000000..5481e77 Binary files /dev/null and b/material/material-extra/askbenefits-lab.pdf differ diff --git a/material/material-extra/deck-ibm-agents.pptx b/material/material-extra/deck-ibm-agents.pptx new file mode 100644 index 0000000..3d0e7ba Binary files /dev/null and b/material/material-extra/deck-ibm-agents.pptx differ diff --git a/material/material-extra/diagrama-arquitectura.png b/material/material-extra/diagrama-arquitectura.png new file mode 100644 index 0000000..ef3967c Binary files /dev/null and b/material/material-extra/diagrama-arquitectura.png differ diff --git a/material/material-extra/manual-completo-bootcamp.pdf b/material/material-extra/manual-completo-bootcamp.pdf new file mode 100644 index 0000000..f80a442 Binary files /dev/null and b/material/material-extra/manual-completo-bootcamp.pdf differ diff --git a/material/material-extra/paper-react-yao-2022.pdf b/material/material-extra/paper-react-yao-2022.pdf new file mode 100644 index 0000000..2932750 Binary files /dev/null and b/material/material-extra/paper-react-yao-2022.pdf differ diff --git a/material/material-extra/qna.csv b/material/material-extra/qna.csv new file mode 100644 index 0000000..6053705 --- /dev/null +++ b/material/material-extra/qna.csv @@ -0,0 +1,51 @@ +Compositor,Pregunta,Respuesta +Mozart,¿Dónde nació Mozart?,"Mozart nació en Salzburgo, que en su época era un arzobispado independiente y hoy es parte de Austria. Fue ahí donde creció rodeado de música gracias a su padre Leopold." +Mozart,¿A qué edad empezó a componer?,"Comenzó a componer desde los 5 años, escribiendo pequeñas piezas para teclado que asombraban por su armonía y estructura pese a su corta edad." +Mozart,¿Cuál es su nombre completo?,"Su nombre completo era Johannes Chrysostomus Wolfgangus Theophilus Mozart, aunque usaba Wolfgang Amadeus Mozart para presentarse profesionalmente." +Mozart,¿Qué significa “Amadeus”?,"Significa 'amado de Dios' en latín, aunque también usaba la versión alemana 'Gottlieb' y la francesa 'Amadé' en cartas y partituras." +Mozart,¿Qué famosa ópera compuso en 1786?,"Compuso 'Las bodas de Fígaro' (Le nozze di Figaro), considerada una de las mayores óperas cómicas de la historia musical." +Mozart,¿Cuál es su ópera considerada la más oscura y profunda?,"Sin duda 'Don Giovanni', que mezcla comedia y drama con tintes filosóficos sobre el pecado, la libertad y la muerte." +Mozart,¿Qué obra dejó inconclusa al morir?,"Su famoso Réquiem en Re menor, que estaba componiendo por encargo cuando falleció repentinamente en 1791." +Mozart,¿Quién terminó su Réquiem?,"Fue su alumno Franz Xaver Süssmayr, quien completó los movimientos faltantes para poder entregar la obra terminada." +Mozart,¿En qué ciudad murió Mozart?,"Murió en Viena, la capital de Austria, donde pasó sus últimos años componiendo sinfonías, óperas y su Réquiem." +Mozart,¿A qué edad murió?,"Murió muy joven, a los 35 años, dejando más de 600 obras maestras que marcaron la historia de la música." +Mozart,¿Cómo murió?,"La causa exacta sigue siendo debatida, pero la teoría más aceptada es fiebre reumática o infección renal, descartando el mito de envenenamiento." +Mozart,¿Cuál era su relación con su padre Leopold?,"Era cercana pero también tensa; Leopold controlaba su carrera y vida personal, mientras Mozart quería independencia." +Mozart,¿Mozart fue famoso en vida?,"Sí, era admirado en Europa como niño prodigio y compositor, aunque enfrentó problemas financieros en sus últimos años por su estilo de vida y falta de ahorros." +Mozart,¿Cuál fue su primera sinfonía?,"La Sinfonía No. 1 en Mi bemol mayor, K.16, compuesta cuando tenía solo 8 años, demostrando su talento precoz." +Mozart,¿Cuál es su sinfonía más famosa?,"La Sinfonía No. 40 en Sol menor, conocida por su energía dramática y belleza melódica que sigue impactando al público." +Mozart,¿Cuál es su última sinfonía?,"La Sinfonía No. 41 'Júpiter', considerada su sinfonía más grandiosa y compleja en estructura polifónica." +Mozart,¿Qué pieza para piano es muy popular de Mozart?,"El 'Rondo Alla Turca' (Marcha Turca) de su Sonata K.331, que imita la percusión turca muy de moda en su época." +Mozart,¿Con qué estilo musical se asocia Mozart?,"Es uno de los máximos representantes del clasicismo vienés, caracterizado por equilibrio, claridad y elegancia formal." +Mozart,¿Tocaba algún instrumento además del piano?,"Sí, además de ser virtuoso al piano, tocaba muy bien el violín desde niño, enseñado por su padre." +Mozart,¿Con qué frase se le suele describir?,Se le llama 'genio musical precoz' por su dominio de la composición desde edad temprana y su obra inmensa en tan pocos años. +Beethoven,¿Dónde nació Beethoven?,"Nació en Bonn, Alemania, en el seno de una familia de músicos que impulsaron su carrera desde pequeño." +Beethoven,¿Qué problema de salud tuvo en su vida adulta?,"Sufrió sordera progresiva hasta quedar completamente sordo en sus últimos años, lo cual no detuvo su genialidad." +Beethoven,¿Cuál es su obra más famosa?,"La Sinfonía No. 9 en Re menor, famosa por su cuarto movimiento con la 'Oda a la Alegría', hoy himno de la Unión Europea." +Beethoven,¿Cómo se llama su única ópera?,"Fidelio, que trata sobre la libertad, la justicia y el amor conyugal, temas profundos para él." +Beethoven,¿Qué edad tenía cuando empezó a perder audición?,"Empezó a notar problemas auditivos a los 28 años, lo que le causó gran depresión y aislamiento social." +Beethoven,¿Cuál es su sonata para piano más famosa?,"La Sonata No. 14, conocida como 'Claro de Luna', evocadora y profundamente emotiva." +Beethoven,¿Qué representa su Sinfonía No. 3 “Heroica”?,"Es un canto a la heroicidad y el idealismo humano, inicialmente dedicada a Napoleón hasta que se coronó emperador." +Beethoven,¿Por qué retiró la dedicatoria a Napoleón?,"Porque Beethoven lo admiraba como libertador, pero al proclamarse emperador lo consideró traidor a los ideales republicanos." +Beethoven,¿Cuál fue su última sinfonía?,"La Novena Sinfonía, con su revolucionario final coral y mensaje universal de fraternidad." +Beethoven,¿Qué gran innovación introdujo en la Sinfonía No. 9?,"Fue la primera sinfonía en incluir coro y solistas vocales, rompiendo esquemas clásicos." +Beethoven,¿Con qué estilo musical se le asocia?,"Es considerado un puente entre el clasicismo de Mozart y Haydn y el romanticismo de Liszt, Schumann y Wagner." +Beethoven,¿A qué edad murió?,"Murió a los 56 años tras años de enfermedades, pero dejando un legado inmortal." +Beethoven,¿De qué murió Beethoven?,"Principalmente de cirrosis hepática y complicaciones renales, probablemente exacerbadas por su estilo de vida y medicación." +Beethoven,¿Tocaba algún instrumento?,Era un pianista virtuoso y también tocaba el órgano y la viola cuando era joven. +Beethoven,¿Cuál fue su maestro más influyente?,"Joseph Haydn, aunque tuvieron diferencias creativas, Haydn reconoció su genialidad." +Beethoven,¿Cómo era su carácter?,"Tenía un temperamento fuerte, independiente y a veces explosivo, pero era muy sensible y comprometido con su arte." +Beethoven,¿Cómo se llama su testamento personal y filosófico?,"El Testamento de Heiligenstadt, carta donde expresa su desesperación por la sordera y su decisión de seguir viviendo por su arte." +Beethoven,¿En qué ciudad murió?,"Murió en Viena, donde pasó la mayor parte de su vida como compositor y pianista." +Beethoven,¿Qué pieza para piano expresa su frustración y lucha?,"La Sonata No. 8 'Patética', llena de contrastes dramáticos y ternura." +Beethoven,¿Cómo se llama la pieza breve que se cree compuso para una mujer llamada Therese?,"Für Elise, pieza de dificultad media que se ha vuelto un clásico de aprendizaje pianístico." +Liszt,¿Dónde nació Liszt?,"Nació en Raiding, un pueblo húngaro que hoy pertenece a Austria, y desde joven mostró virtuosismo al piano." +Liszt,¿Qué fue Liszt además de compositor?,"Fue el pianista más famoso de su tiempo, director de orquesta, maestro y promotor de otros músicos." +Liszt,¿Qué técnica pianística se le atribuye haber perfeccionado?,"Elevó el recital de piano a un espectáculo con sus innovadoras técnicas de arpegios, octavas rápidas y transcripciones orquestales." +Liszt,¿Qué significa “Lisztomanía”?,Era el término para describir la histeria colectiva y adoración casi idolátrica que causaba en sus conciertos. +Liszt,¿Cuál es su obra pianística más famosa?,"La Sonata en Si menor, considerada una cumbre pianística, junto con sus Estudios Trascendentales por su dificultad y expresividad." +Liszt,¿Qué relación tuvo con Wagner?,"Fue suegro de Wagner y gran promotor de su música, aunque su relación fue compleja y distante por épocas." +Liszt,¿Qué hizo Liszt en su madurez?,"Se retiró parcialmente de los escenarios y se ordenó como abad, dedicándose a la música religiosa y pedagógica." +Liszt,¿Cuál es su poema sinfónico más famoso?,"Les Préludes, una obra que ejemplifica su desarrollo del poema sinfónico como género musical." +Liszt,¿Con qué estilo musical se le asocia?,"Con el romanticismo, destacando por su pasión, virtuosismo y creación del poema sinfónico." +Liszt,¿A qué edad murió y dónde?,"Murió a los 74 años en Bayreuth, Alemania, durante el festival de ópera organizado por Wagner." \ No newline at end of file diff --git a/material/openapi-tools-report.json b/material/openapi-tools-report.json new file mode 100644 index 0000000..16ff99f --- /dev/null +++ b/material/openapi-tools-report.json @@ -0,0 +1,72 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Report API", + "version": "2.0.0", + "description": "Tools for generating HTML reports with WxO" + }, + "servers": [ + { + "url": "https://taller-wox.fitlabs.dev/api/reports/" + } + ], + "paths": { + "/generate-report": { + "post": { + "summary": "Generate Performance Report", + "description": "This API generates a custom HTML performance report using predefined or user-defined layout sections.\n\n### How It Works\nThe input is a JSON-encoded list (`layout_config`) that specifies the structure of the report. Each item in the list must be one of:\n\n1. A **string**, such as \"care_report\", referring to a predefined layout preset.\n2. A **dictionary**, with:\n - `element_type`: The name of the registered element (e.g., `header`, `overview`).\n - `parameters`: A dictionary of arguments for that element function.\n\n### Predefined Presets\n- \"care_report\": A ready-to-use layout including a report header, overview generated by a language model, a claim review chart, and a data table from a CSV file.\n\n### Available Element Types\n\n#### ✅ `header`\n- **Description**: Creates a centered H2 title.\n- **Parameters**:\n - `title` (string, required): Text to display in the header.\n- **Example**:\n ```json\n {\n \"element_type\": \"header\",\n \"parameters\": {\n \"title\": \"Monthly Summary\"\n }\n }\n ```\n\n#### ✅ `overview`\n- **Description**: Uses a prompt + text file to generate a markdown-based summary via a language model.\n- **Parameters**:\n - `prompt` (string, required): Prompt prepended to the file contents.\n - `text_file` (string, required): Path to the `.txt` file to append to the prompt.\n - `title` (string, required): Display title for the section.\n- **Example**:\n ```json\n {\n \"element_type\": \"overview\",\n \"parameters\": {\n \"prompt\": \"Summarize customer sentiment: \",\n \"text_file\": \"./data/combined_email.txt\",\n \"title\": \"Customer Overview\"\n }\n }\n ```\n\n#### ✅ `claim_review_chart`\n- **Description**: Visualizes synthetic insurance payment breakdowns for multiple CPT codes.\n- **Parameters**: none required\n- **Example**:\n ```json\n {\n \"element_type\": \"claim_review_chart\",\n \"parameters\": {}\n }\n ```\n\n#### ✅ `table`\n- **Description**: Renders a CSV as a Plotly HTML table.\n- **Parameters**:\n - `csv_file` (string, required): Path to the `.csv` file.\n - `title` (string, required): Title for the visualization.\n- **Example**:\n ```json\n {\n \"element_type\": \"table\",\n \"parameters\": {\n \"csv_file\": \"./data/aetna_claim_review_summary.csv\",\n \"title\": \"Claim Review Table\"\n }\n }\n ```\n\n### 🧩 Layout Input Format\n- You may nest arrays or mix strings and dictionaries.\n- Only one level of nesting is resolved.\n- Final structure is flattened and rendered in order.\n\n### 🚨 File Usage Rules\n- Files must be located in the `./data/` directory.\n- Approved file types and paths:\n - `.txt`: For use with `overview`\n - `./data/combined_email.txt`\n - `./data/provider_email.txt`\n - `./data/aetna_email.txt`\n - `.csv`: For use with `table`\n - `./data/aetna_claim_review_summary.csv`\n\n⚠️ Do not reuse or cross-reference files across incompatible element types.\n\n### 🔁 Example Full Report Layout\n```json\n[\n \"care_report\",\n {\n \"element_type\": \"header\",\n \"parameters\": { \"title\": \"End of Report\" }\n }\n]\n```", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "layout_config": { + "type": "string", + "description": "A JSON-encoded string representing a list of section keys and/or custom report elements." + } + }, + "required": ["layout_config"] + } + } + } + }, + "responses": { + "200": { + "description": "The HTML report was successfully generated and uploaded.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "public_url": { + "type": "string", + "format": "uri", + "example": "https://your-bucket.cloud-object-storage.appdomain.cloud/reports/report.html" + } + } + } + } + } + }, + "500": { + "description": "An error occurred while generating the report.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } \ No newline at end of file diff --git a/material/openapi-tools-spec.json b/material/openapi-tools-spec.json new file mode 100644 index 0000000..8b55fd2 --- /dev/null +++ b/material/openapi-tools-spec.json @@ -0,0 +1,146 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "BenefitsAgent Tools", + "version": "1.0.0" + }, + "servers": [ + { + "url": "https://taller-wox.fitlabs.dev/api/", + "description": "Local development server" + } + ], + "paths": { + "/historical-procedures": { + "post": { + "summary": "This tool enables intelligent analysis and summarization of the historical procedures dataset.", + "description": "This tool enables intelligent analysis and summarization of the historical procedures dataset.\n### Key Features\n- Filtering on any combination of columns\n- Grouping and aggregation of results\n### Dataset Overview\nDetected columns:\n- **member_name**: e.g., Alice, Bob, Charlie...\n- **relationship**: e.g., Mother, Father, Son...\n- **age**: e.g., 42, 45, 12...\n- **gender**: e.g., Female, Male\n- **procedure**: e.g., Annual Physical Exam, Appendectomy, CT Scan...\n- **procedure_type**: e.g., preventive, surgery, diagnostic...\n- **location**: e.g., City Hospital, Green Valley Clinic, Sunrise Health...\n- **date**: e.g., 2024-04-28, 2023-05-02, 2022-05-11...\n- **in_network**: e.g., True, False\n- **member_plan**: e.g., Gold PPO, Family Plan - Silver EPO\n- **accepted_plans**: e.g., Gold PPO, Family Plan - Silver EPO, Medicare Advantage, Gold PPO, Family Plan - Silver EPO, Family Plan - Silver EPO, Medicare Advantage...\n- **cost_facility**: e.g., 29.4, 48.02, 30.8...\n- **cost_physician**: e.g., 199.09, 189.75, 128.9...\n- **cost_anesthesia**: e.g., 0.0, 2024.21, 2257.4...\n- **cost_medication**: e.g., 4.19, 5.53, 7.15...\n- **total_cost**: e.g., 232.68, 243.3, 166.85...\n- **facility_rating**: e.g., 4.7, 4.5, 4.6...\n- **notes**: e.g., Annual Physical Exam performed at City Hospital., Appendectomy performed at City Hospital., CT Scan performed at Green Valley Clinic....\n### Example Input:\n```json\n{\"filters\": [{\"column\": \"member_name\", \"operator\": \"equals\", \"value\": \"Alice\"}]}\n```\nExample Questions:\n- \"What rows match member_name = Alice?\" \u2192 `{\"filters\": [{\"column\": \"member_name\", \"operator\": \"equals\", \"value\": \"Alice\"}]}`", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProcedureQuery" + } + } + } + }, + "responses": { + "200": { + "description": "Successful result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "object" + } + } + } + } + } + } + } + } + }, + "/available-procedures": { + "post": { + "summary": "This tool enables intelligent analysis and summarization of the available procedures dataset.", + "description": "This tool enables intelligent analysis and summarization of the available procedures dataset.\n### Key Features\n- Filtering on any combination of columns\n- Grouping and aggregation of results\n### Dataset Overview\nDetected columns:\n- **procedure**: e.g., Angioplasty, Annual Physical Exam, Appendectomy...", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProcedureQuery" + } + } + } + }, + "responses": { + "200": { + "description": "Successful result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "object" + } + } + } + } + } + } + } + } + }, + "/member-insights": { + "get": { + "summary": "Returns member data including plan information and overdue procedures.", + "description": "Returns member data including:\n- Plan information (medical, pharmacy, mental health, wellness, tax documents)\n- Overdue preventive procedures", + "responses": { + "200": { + "description": "Member profile and plan data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "object" + } + } + } + } + } + } + } + } + }, + "/schedule": { + "get": { + "summary": "Provides appointment scheduling guidance.", + "description": "Provides appointment scheduling guidelines to help users arrange medical visits through a live scheduling system.", + "responses": { + "200": { + "description": "Synthetic appointment instructions", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ProcedureQuery": { + "type": "object", + "properties": { + "filters": { + "type": "string", + "description": "JSON-encoded list of filter objects", + "example": "[{\"column\": \"procedure\", \"operator\": \"equals\", \"value\": \"X ray\"}]" + }, + "group_by": { + "type": "string", + "description": "JSON-encoded list of columns to group by", + "example": "[\"facility_rating\"]" + } + } + } + } + } +} diff --git a/material/openapi-tools-spec.yaml b/material/openapi-tools-spec.yaml new file mode 100644 index 0000000..9a570dd --- /dev/null +++ b/material/openapi-tools-spec.yaml @@ -0,0 +1,355 @@ +openapi: 3.0.3 +info: + title: BenefitsAgent Tools + version: 1.0.0 +servers: +- url: https://taller-wox.fitlabs.dev/api + description: Local development server +paths: + /historical-procedures: + post: + summary: This tool enables intelligent analysis and summarization of the historical + procedures dataset. + description: |- + This tool enables intelligent analysis and summarization of the historical procedures dataset. + ### Key Features + - Filtering on any combination of columns + - Grouping and aggregation of results + ### Dataset Overview + Detected columns: + - **member_name**: e.g., Alice, Bob, Charlie... + - **relationship**: e.g., Mother, Father, Son... + - **age**: e.g., 42, 45, 12... + - **gender**: e.g., Female, Male + - **procedure**: e.g., Annual Physical Exam, Appendectomy, CT Scan... + - **procedure_type**: e.g., preventive, surgery, diagnostic... + - **location**: e.g., City Hospital, Green Valley Clinic, Sunrise Health... + - **date**: e.g., 2024-04-28, 2023-05-02, 2022-05-11... + - **in_network**: e.g., True, False + - **member_plan**: e.g., Gold PPO, Family Plan - Silver EPO + - **accepted_plans**: e.g., Gold PPO, Family Plan - Silver EPO, Medicare Advantage, Gold PPO, Family Plan - Silver EPO, Family Plan - Silver EPO, Medicare Advantage... + - **cost_facility**: e.g., 29.4, 48.02, 30.8... + - **cost_physician**: e.g., 199.09, 189.75, 128.9... + - **cost_anesthesia**: e.g., 0.0, 2024.21, 2257.4... + - **cost_medication**: e.g., 4.19, 5.53, 7.15... + - **total_cost**: e.g., 232.68, 243.3, 166.85... + - **facility_rating**: e.g., 4.7, 4.5, 4.6... + - **notes**: e.g., Annual Physical Exam performed at City Hospital., Appendectomy performed at City Hospital., CT Scan performed at Green Valley Clinic.... + ### Example Input: + ```json + { + "filters": [ + { + "column": "member_name", + "operator": "equals", + "value": "Alice" + } + ] + } + { + "filters": [ + { + "column": "member_name", + "operator": "contains", + "value": "Ali" + } + ] + } + { + "filters": [ + { + "column": "member_name", + "operator": "ne", + "value": "Alice" + } + ] + } + { + "filters": [ + { + "column": "age", + "operator": "gt", + "value": 22.69 + } + ] + } + { + "filters": [ + { + "column": "age", + "operator": "lt", + "value": 27.73 + } + ] + } + { + "filters": [ + { + "column": "age", + "operator": "ge", + "value": 25 + } + ] + } + { + "filters": [ + { + "column": "age", + "operator": "le", + "value": 25 + } + ] + } + { + "filters": [ + { + "column": "age", + "operator": "ne", + "value": 25 + } + ] + } + { + "group_by": [ + "member_name", + "relationship" + ] + } + { + "filters": [ + { + "column": "member_name", + "operator": "equals", + "value": "Alice" + }, + { + "column": "age", + "operator": "ge", + "value": 12.0 + } + ], + "group_by": [ + "relationship" + ] + } + ``` + Example Questions: + - "What rows match member_name = Alice?" → `{"filters": [{"column": "member_name", "operator": "equals", "value": "Alice"}]}` + - "What rows do not match member_name = Alice?" → `{"filters": [{"column": "member_name", "operator": "ne", "value": "Alice"}]}` + - "What rows contain 'Ali' in member_name?" → `{"filters": [{"column": "member_name", "operator": "contains", "value": "Ali"}]}` + - "What rows have age greater than 25.21?" → `{"filters": [{"column": "age", "operator": "gt", "value": 25.21}]}` + - "What rows have age less than 25.21?" → `{"filters": [{"column": "age", "operator": "lt", "value": 25.21}]}` + - "What rows have age greater than or equal to 25.21?" → `{"filters": [{"column": "age", "operator": "ge", "value": 25.21}]}` + - "What rows have age less than or equal to 25.21?" → `{"filters": [{"column": "age", "operator": "le", "value": 25.21}]}` + - "What rows do not have age equal to 25.21?" → `{"filters": [{"column": "age", "operator": "ne", "value": 25.21}]}` + - "What is the average of age grouped by member_name?" → `{"group_by": ["member_name"]}` + - "What is the total age for member_name = 25.210526315789473 grouped by relationship?" → `{"filters": [{"column": "member_name", "operator": "equals", "value": "25.210526315789473"}], "group_by": ["relationship"]}` + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProcedureQuery' + responses: + '200': + description: Successful result + content: + application/json: + schema: + type: object + properties: + result: + type: object + /available-procedures: + post: + summary: This tool enables intelligent analysis and summarization of the available + procedures dataset. + description: |- + This tool enables intelligent analysis and summarization of the available procedures dataset. + ### Key Features + - Filtering on any combination of columns + - Grouping and aggregation of results + ### Dataset Overview + Detected columns: + - **procedure**: e.g., Angioplasty, Annual Physical Exam, Appendectomy... + - **location**: e.g., City Hospital, Regional Medical Center, Green Valley Clinic... + - **facility_rating**: e.g., 4.7, 4.3, 4.5... + - **distance_miles**: e.g., 5.2, 12.6, 1.2... + - **gold_ppo_plan_accepted**: e.g., True, False + - **silver_epo_plan_accepted**: e.g., True + - **accepted_plans**: e.g., Gold PPO, Family Plan - Silver EPO, Medicare Advantage, Family Plan - Silver EPO, Bronze HDHP, Medicaid, Gold PPO, Family Plan - Silver EPO... + - **cost_facility**: e.g., 9432.8, 23594.95, 3807.32... + - **cost_physician**: e.g., 4774.57, 4897.27, 5687.8... + - **cost_anesthesia**: e.g., 1894.37, 581.04, 293.41... + - **cost_medication**: e.g., 834.8, 783.86, 1301.58... + - **total_cost**: e.g., 16936.54, 29857.12, 11090.11... + ### Example Input: + ```json + { + "filters": [ + { + "column": "procedure", + "operator": "equals", + "value": "Angioplasty" + } + ] + } + { + "filters": [ + { + "column": "procedure", + "operator": "contains", + "value": "Ang" + } + ] + } + { + "filters": [ + { + "column": "procedure", + "operator": "ne", + "value": "Angioplasty" + } + ] + } + { + "filters": [ + { + "column": "facility_rating", + "operator": "gt", + "value": 4.01 + } + ] + } + { + "filters": [ + { + "column": "facility_rating", + "operator": "lt", + "value": 4.9 + } + ] + } + { + "filters": [ + { + "column": "facility_rating", + "operator": "ge", + "value": 4 + } + ] + } + { + "filters": [ + { + "column": "facility_rating", + "operator": "le", + "value": 4 + } + ] + } + { + "filters": [ + { + "column": "facility_rating", + "operator": "ne", + "value": 4 + } + ] + } + { + "group_by": [ + "procedure", + "location" + ] + } + { + "filters": [ + { + "column": "procedure", + "operator": "equals", + "value": "Angioplasty" + }, + { + "column": "facility_rating", + "operator": "ge", + "value": 4.5 + } + ], + "group_by": [ + "location" + ] + } + ``` + Example Questions: + - "What rows match procedure = Angioplasty?" → `{"filters": [{"column": "procedure", "operator": "equals", "value": "Angioplasty"}]}` + - "What rows do not match procedure = Angioplasty?" → `{"filters": [{"column": "procedure", "operator": "ne", "value": "Angioplasty"}]}` + - "What rows contain 'Ang' in procedure?" → `{"filters": [{"column": "procedure", "operator": "contains", "value": "Ang"}]}` + - "What rows have facility_rating greater than 4.45?" → `{"filters": [{"column": "facility_rating", "operator": "gt", "value": 4.45}]}` + - "What rows have facility_rating less than 4.45?" → `{"filters": [{"column": "facility_rating", "operator": "lt", "value": 4.45}]}` + - "What rows have facility_rating greater than or equal to 4.45?" → `{"filters": [{"column": "facility_rating", "operator": "ge", "value": 4.45}]}` + - "What rows have facility_rating less than or equal to 4.45?" → `{"filters": [{"column": "facility_rating", "operator": "le", "value": 4.45}]}` + - "What rows do not have facility_rating equal to 4.45?" → `{"filters": [{"column": "facility_rating", "operator": "ne", "value": 4.45}]}` + - "What is the average of facility_rating grouped by procedure?" → `{"group_by": ["procedure"]}` + - "What is the total facility_rating for procedure = 4.450442477876106 grouped by location?" → `{"filters": [{"column": "procedure", "operator": "equals", "value": "4.450442477876106"}], "group_by": ["location"]}` + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProcedureQuery' + responses: + '200': + description: Successful result + content: + application/json: + schema: + type: object + properties: + result: + type: object + /member-insights: + get: + summary: Returns member data including plan information and overdue procedures. + description: |- + Returns member data including: + - Plan information (medical, pharmacy, mental health, wellness, tax documents) + - Overdue preventive procedures + responses: + '200': + description: Member profile and plan data + content: + application/json: + schema: + type: object + properties: + result: + type: object + /schedule: + get: + summary: Provides appointment scheduling guidance. + description: Provides appointment scheduling guidelines to help users arrange + medical visits through a live scheduling system. + responses: + '200': + description: Synthetic appointment instructions + content: + application/json: + schema: + type: object + properties: + result: + type: string +components: + schemas: + ProcedureQuery: + type: object + properties: + filters: + type: string + description: JSON-encoded list of filter objects + example: '[{"column": "procedure", "operator": "equals", "value": "X ray"}]' + group_by: + type: string + description: JSON-encoded list of columns to group by + example: '["facility_rating"]' diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..007ff3d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +fastapi==0.115.0 +uvicorn[standard]==0.32.0 +pydantic==2.9.2 +pydantic-settings==2.6.1 +pydantic[email] +python-multipart==0.0.12 +itsdangerous==2.2.0 +jinja2==3.1.4 +pandas==2.2.3 +plotly==5.24.1 +pytest==8.3.3 +httpx==0.27.2 diff --git a/static/css/styles.css b/static/css/styles.css new file mode 100644 index 0000000..5a4db98 --- /dev/null +++ b/static/css/styles.css @@ -0,0 +1,109 @@ +:root { + --navy: #0A1F44; + --blue: #1E4FA8; + --cyan: #00B5D8; + --orange: #FF7A00; + --orange-hover: #E66A00; + --cream: #F5F7FA; + --text: #1A1A1A; + --muted: #5A6473; + --border: #D8DEE5; +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { margin: 0; font-family: 'Inter', system-ui, sans-serif; color: var(--text); background: white; line-height: 1.6; } + +.site-header { + display: flex; align-items: center; justify-content: space-between; + padding: 16px 32px; background: white; border-bottom: 1px solid var(--border); +} +.site-header .brand img { display: block; } +.powered-by { color: var(--muted); font-size: 13px; } +.powered-by strong { color: var(--navy); } + +.hero { + background: linear-gradient(135deg, var(--navy) 0%, var(--blue) 100%); + color: white; padding: 80px 32px; min-height: 80vh; display: flex; align-items: center; + position: relative; overflow: hidden; +} +.hero::before { + content: ''; position: absolute; top: -50px; right: -50px; width: 300px; height: 300px; + background: radial-gradient(circle, rgba(0, 181, 216, 0.15) 0%, transparent 70%); + pointer-events: none; +} +.hero::after { + content: ''; position: absolute; bottom: -80px; left: -80px; width: 400px; height: 400px; + background: radial-gradient(circle, rgba(255, 122, 0, 0.10) 0%, transparent 70%); + pointer-events: none; +} +.hero-inner { max-width: 800px; margin: 0 auto; text-align: center; position: relative; z-index: 1; } +.eyebrow { color: var(--cyan); font-weight: 700; letter-spacing: 2px; margin: 0 0 16px; } +.hero h1 { font-size: clamp(32px, 5vw, 56px); margin: 0 0 16px; font-weight: 800; line-height: 1.15; } +.subtitle { font-size: 20px; color: var(--cream); font-style: italic; margin: 0 0 32px; } + +.btn { display: inline-block; padding: 14px 28px; border-radius: 8px; font-weight: 700; text-decoration: none; cursor: pointer; border: 0; font-size: 16px; transition: transform 0.15s ease, background 0.15s ease, box-shadow 0.15s ease; } +.btn-primary { background: var(--orange); color: white; box-shadow: 0 4px 12px rgba(255, 122, 0, 0.3); } +.btn-primary:hover { background: var(--orange-hover); transform: translateY(-1px); box-shadow: 0 6px 16px rgba(255, 122, 0, 0.4); } +.btn-large { padding: 18px 36px; font-size: 18px; } +.btn-disabled { background: var(--border); color: var(--muted); cursor: not-allowed; } + +.section-title { text-align: center; font-size: 32px; color: var(--navy); margin: 0 0 32px; } + +.cards-section, .stats-section, .form-section, .descargas-section { + padding: 64px 32px; max-width: 1100px; margin: 0 auto; +} +.cards-section { background: var(--cream); max-width: none; } +.cards-section .section-title, .cards-section .cards { max-width: 1100px; margin-left: auto; margin-right: auto; } + +.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 24px; } +.card { background: white; padding: 32px; border-radius: 12px; box-shadow: 0 2px 8px rgba(10, 31, 68, 0.08); transition: transform 0.2s ease, box-shadow 0.2s ease; } +.card:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(10, 31, 68, 0.14); } +.card .icon { font-size: 36px; margin-bottom: 12px; } +.card h3 { color: var(--navy); margin: 0 0 8px; } +.card p { color: var(--muted); margin: 0; } + +.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 24px; text-align: center; } +.stat { padding: 24px; } +.stat .num { display: block; font-size: 56px; font-weight: 800; color: var(--orange); line-height: 1; } +.stat .label { display: block; color: var(--muted); margin-top: 8px; font-size: 14px; text-transform: uppercase; letter-spacing: 1px; } + +.form-section { background: white; max-width: 600px; } +.form-intro { text-align: center; color: var(--muted); margin: 0 0 32px; } +.register-form { display: flex; flex-direction: column; gap: 20px; } +.field label { display: block; font-weight: 600; margin-bottom: 6px; color: var(--navy); } +.field input[type="text"], .field input[type="email"] { + width: 100%; padding: 12px 14px; border: 2px solid var(--border); border-radius: 8px; font-size: 16px; font-family: inherit; +} +.field input:focus { outline: none; border-color: var(--cyan); } +.field-checkbox { display: flex; gap: 12px; align-items: flex-start; } +.field-checkbox input { margin-top: 4px; } +.field-checkbox label { font-weight: 400; color: var(--muted); font-size: 14px; } +.hp-field { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden; } + +.banner { padding: 16px 32px; text-align: center; } +.banner-error { background: #FFF1F0; color: #B0211A; border-bottom: 2px solid #B0211A; } + +.descargas-section { text-align: center; } +.hello { color: var(--navy); font-size: 40px; margin: 0 0 8px; } +.hello-sub { color: var(--muted); margin: 0 0 48px; } +.download-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 32px; } +.download-card { background: var(--cream); padding: 40px; border-radius: 16px; border: 2px solid transparent; transition: border-color 0.2s ease, transform 0.2s ease; } +.download-card:hover { border-color: var(--cyan); transform: translateY(-2px); } +.download-card .dl-icon { font-size: 56px; margin-bottom: 16px; } +.download-card h2 { color: var(--navy); margin: 0 0 8px; font-size: 24px; } +.download-card p { color: var(--muted); margin: 0 0 8px; } +.download-card .size { font-size: 13px; color: var(--muted); font-weight: 600; } + +.site-footer { background: var(--navy); color: white; padding: 32px; margin-top: 64px; } +.footer-inner { max-width: 1100px; margin: 0 auto; text-align: center; } +.footer-inner a { color: var(--cyan); text-decoration: none; } +.copyright { color: rgba(255, 255, 255, 0.5); font-size: 13px; margin-top: 16px; } + +@media (max-width: 768px) { + .hero { min-height: 70vh; padding: 48px 24px; } + .cards-section, .stats-section, .form-section, .descargas-section { padding: 48px 24px; } + .download-card { padding: 24px; } + .site-header { padding: 12px 20px; } + .powered-by { font-size: 11px; } +} diff --git a/static/img/.gitkeep b/static/img/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/static/img/LogoFIT.png b/static/img/LogoFIT.png new file mode 100644 index 0000000..c5153ec Binary files /dev/null and b/static/img/LogoFIT.png differ diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..8470aff --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,20 @@ +document.addEventListener('DOMContentLoaded', () => { + const sections = document.querySelectorAll('.cards-section, .stats-section, .form-section'); + if (!('IntersectionObserver' in window)) return; + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + entry.target.style.opacity = '1'; + entry.target.style.transform = 'translateY(0)'; + observer.unobserve(entry.target); + } + }); + }, { threshold: 0.1 }); + + sections.forEach(s => { + s.style.opacity = '0'; + s.style.transform = 'translateY(20px)'; + s.style.transition = 'opacity 0.6s ease, transform 0.6s ease'; + observer.observe(s); + }); +}); diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..412f7f9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,38 @@ +import os +import tempfile +from pathlib import Path + +import pytest + +os.environ["SECRET_KEY"] = "test-secret-key-only-for-tests-not-secure" +os.environ["ADMIN_USER"] = "testadmin" +os.environ["ADMIN_PASS"] = "testpass" +os.environ["TOKEN_EXPIRY_HOURS"] = "24" +os.environ["BASE_URL"] = "http://testserver" + +_tmp = Path(tempfile.mkdtemp(prefix="taller-wox-test-")) +os.environ["DB_PATH"] = str(_tmp / "test_leads.db") +os.environ["MATERIAL_DIR"] = str(_tmp / "material") +os.environ["REPORTS_OUTPUT_DIR"] = str(_tmp / "reports_output") +(_tmp / "material").mkdir(parents=True, exist_ok=True) +(_tmp / "reports_output").mkdir(parents=True, exist_ok=True) + + +@pytest.fixture +def client(): + from fastapi.testclient import TestClient + + from app.main import app + return TestClient(app) + + +@pytest.fixture(autouse=True) +def _init_db_each_test(tmp_path, monkeypatch): + db_file = tmp_path / "test.db" + monkeypatch.setenv("DB_PATH", str(db_file)) + from app import config + config.get_settings.cache_clear() + from app.db import init_db + init_db() + yield + config.get_settings.cache_clear() diff --git a/tests/test_admin.py b/tests/test_admin.py new file mode 100644 index 0000000..d69320d --- /dev/null +++ b/tests/test_admin.py @@ -0,0 +1,56 @@ +import base64 + + +def _auth_header(user: str = "testadmin", password: str = "testpass") -> dict: + token = base64.b64encode(f"{user}:{password}".encode()).decode() + return {"Authorization": f"Basic {token}"} + + +def test_admin_leads_requires_auth(client): + response = client.get("/admin/leads.json") + assert response.status_code == 401 + + +def test_admin_leads_with_bad_credentials_rejected(client): + response = client.get("/admin/leads.json", headers=_auth_header(password="wrong")) + assert response.status_code == 401 + + +def test_admin_leads_json_returns_list(client): + client.post( + "/register", + data={"nombre": "Ana", "email": "a@a.com", "empresa": "ACo", "consentimiento": "on"}, + ) + response = client.get("/admin/leads.json", headers=_auth_header()) + assert response.status_code == 200 + body = response.json() + assert isinstance(body, list) + assert any(l["email"] == "a@a.com" for l in body) + + +def test_admin_leads_csv_returns_csv(client): + client.post( + "/register", + data={"nombre": "Beto", "email": "b@b.com", "empresa": "BCo", "consentimiento": "on"}, + ) + response = client.get("/admin/leads.csv", headers=_auth_header()) + assert response.status_code == 200 + assert "text/csv" in response.headers["content-type"] + body = response.text + assert body.startswith("") + assert "b@b.com" in body + + +def test_admin_stats_returns_counts(client): + client.post( + "/register", + data={"nombre": "Carla", "email": "c@c.com", "empresa": "CCo", "consentimiento": "on"}, + ) + response = client.get("/admin/stats", headers=_auth_header()) + assert response.status_code == 200 + body = response.json() + assert "total_leads" in body + assert "total_downloads" in body + assert "downloads_por_archivo" in body + assert "top_5_empresas" in body + assert body["total_leads"] >= 1 diff --git a/tests/test_benefits_api.py b/tests/test_benefits_api.py new file mode 100644 index 0000000..cbb750c --- /dev/null +++ b/tests/test_benefits_api.py @@ -0,0 +1,85 @@ +def test_member_insights_returns_static_object(client): + response = client.get("/api/member-insights") + assert response.status_code == 200 + body = response.json() + assert body["result"]["member"]["name"] == "Charlie Smith" + assert body["result"]["member"]["plan"] == "Gold PPO" + assert len(body["result"]["overdue_procedures"]) == 4 + + +def test_schedule_returns_text_wrapped(client): + response = client.get("/api/schedule") + assert response.status_code == 200 + body = response.json() + assert "agendar una cita" in body["result"].lower() + assert "1-800-FIT-CARE" in body["result"] + + +def test_historical_procedures_no_filter_returns_all(client): + response = client.post( + "/api/historical-procedures", + json={"filters": "[]", "group_by": "[]"}, + ) + assert response.status_code == 200 + assert len(response.json()["result"]) == 50 + + +def test_historical_procedures_filter_equals(client): + response = client.post( + "/api/historical-procedures", + json={ + "filters": '[{"column": "member_name", "operator": "equals", "value": "Charlie Smith"}]', + "group_by": "[]", + }, + ) + rows = response.json()["result"] + assert len(rows) == 5 + assert all(r["member_name"] == "Charlie Smith" for r in rows) + + +def test_historical_procedures_filter_contains(client): + response = client.post( + "/api/historical-procedures", + json={ + "filters": '[{"column": "procedure", "operator": "contains", "value": "mri"}]', + "group_by": "[]", + }, + ) + rows = response.json()["result"] + assert len(rows) > 0 + assert all("MRI" in r["procedure"] for r in rows) + + +def test_historical_procedures_filter_gt(client): + response = client.post( + "/api/historical-procedures", + json={ + "filters": '[{"column": "total_cost", "operator": "gt", "value": 5000}]', + "group_by": "[]", + }, + ) + rows = response.json()["result"] + assert len(rows) > 0 + assert all(r["total_cost"] > 5000 for r in rows) + + +def test_available_procedures_no_filter(client): + response = client.post( + "/api/available-procedures", + json={"filters": "[]", "group_by": "[]"}, + ) + rows = response.json()["result"] + assert len(rows) == 28 + + +def test_available_procedures_filter_mri(client): + response = client.post( + "/api/available-procedures", + json={ + "filters": '[{"column": "procedure", "operator": "contains", "value": "MRI"}]', + "group_by": "[]", + }, + ) + rows = response.json()["result"] + assert len(rows) >= 3 + assert all(r["procedure"] == "MRI" for r in rows) diff --git a/tests/test_frontend.py b/tests/test_frontend.py new file mode 100644 index 0000000..78dac1e --- /dev/null +++ b/tests/test_frontend.py @@ -0,0 +1,122 @@ +import os +from pathlib import Path + + +def test_landing_renders(client): + response = client.get("/") + assert response.status_code == 200 + assert "Bootcamp Agentic AI" in response.text + assert 'name="email"' in response.text + assert 'name="website"' in response.text + + +def test_register_valid_form_redirects_with_token(client): + response = client.post( + "/register", + data={ + "nombre": "Felipe Arentsen", + "email": "felipe@factorit.com", + "empresa": "FactorIT", + "consentimiento": "on", + }, + follow_redirects=False, + ) + assert response.status_code == 303 + assert response.headers["location"].startswith("/descargas?token=") + + +def test_register_honeypot_filled_silently_drops(client): + response = client.post( + "/register", + data={ + "nombre": "Bot", + "email": "bot@spam.com", + "empresa": "Spam Inc", + "consentimiento": "on", + "website": "https://spam.com", + }, + follow_redirects=False, + ) + assert response.status_code == 303 + from app.db import get_lead_by_email + assert get_lead_by_email("bot@spam.com") is None + + +def test_register_duplicate_email_reissues_token(client): + for _ in range(2): + client.post( + "/register", + data={ + "nombre": "Felipe", + "email": "dup@factorit.com", + "empresa": "FactorIT", + "consentimiento": "on", + }, + follow_redirects=False, + ) + from app.db import get_lead_by_email + lead = get_lead_by_email("dup@factorit.com") + assert lead["times_registered"] == 2 + + +def test_descargas_with_invalid_token_redirects_home(client): + response = client.get("/descargas?token=invalid-junk", follow_redirects=False) + assert response.status_code == 307 + assert response.headers["location"].startswith("/?error=") + + +def test_descargas_with_valid_token_renders(client): + reg = client.post( + "/register", + data={ + "nombre": "Maria", + "email": "maria@test.com", + "empresa": "Test Co", + "consentimiento": "on", + }, + follow_redirects=False, + ) + token = reg.headers["location"].split("token=")[1] + response = client.get(f"/descargas?token={token}") + assert response.status_code == 200 + assert "Hola Maria" in response.text + assert "Material técnico" in response.text + assert "Material funcional" in response.text + + +def test_download_with_valid_token_serves_file(client): + material_dir = Path(os.environ["MATERIAL_DIR"]) + material_dir.mkdir(parents=True, exist_ok=True) + (material_dir / "taller-wox-tecnico.zip").write_bytes(b"PK\x03\x04 fake zip") + + reg = client.post( + "/register", + data={ + "nombre": "Test", + "email": "test-dl@test.com", + "empresa": "TC", + "consentimiento": "on", + }, + follow_redirects=False, + ) + token = reg.headers["location"].split("token=")[1] + response = client.get(f"/download/taller-wox-tecnico.zip?token={token}") + assert response.status_code == 200 + assert response.content == b"PK\x03\x04 fake zip" + assert "attachment" in response.headers["content-disposition"] + + +def test_download_invalid_filename_returns_404(client): + reg = client.post( + "/register", + data={ + "nombre": "Test", + "email": "bad-fn@test.com", + "empresa": "TC", + "consentimiento": "on", + }, + follow_redirects=False, + ) + token = reg.headers["location"].split("token=")[1] + response = client.get(f"/download/etc-passwd?token={token}") + assert response.status_code == 404 diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..e76b33e --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,5 @@ +def test_health(client): + response = client.get("/health") + assert response.status_code == 200 + body = response.json() + assert body["status"] == "ok" diff --git a/tests/test_reports_api.py b/tests/test_reports_api.py new file mode 100644 index 0000000..8444864 --- /dev/null +++ b/tests/test_reports_api.py @@ -0,0 +1,23 @@ +import json +import os +from pathlib import Path + + +def test_generate_report_with_care_report_preset(client): + response = client.post( + "/api/reports/generate-report", + json={"layout_config": json.dumps(["care_report"])}, + ) + assert response.status_code == 200 + body = response.json() + assert "public_url" in body + assert body["public_url"].endswith(".html") + + report_id = body["public_url"].rsplit("/", 1)[-1] + output_dir = Path(os.environ["REPORTS_OUTPUT_DIR"]) + assert (output_dir / report_id).exists() + content = (output_dir / report_id).read_text() + assert "Care Report" in content + assert "Customer Overview" in content + assert "Claim Review Summary" in content + assert "