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) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 03:01:44 +00:00
commit 041e2325e7
56 changed files with 7992 additions and 0 deletions

8
.env.example Normal file
View File

@@ -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

14
.gitignore vendored Normal file
View File

@@ -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/

21
Dockerfile Normal file
View File

@@ -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"]

72
README.md Normal file
View File

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

848
SPEC_taller_wox_fitlabs.md Normal file
View File

@@ -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
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
```
**Logos a mostrar (en el header):**
- Logo FactorIT (izquierda) — ya está en `static/img/logo-factorit.svg`
- Logo IBM watsonx Orchestrate (derecha, más pequeño, como "powered by") — bajar de https://www.ibm.com/brand/watsonx (o usar un placeholder hasta tener uno oficial)
### 3.2 Página principal `GET /`
Estructura visual (una sola página, scroll vertical):
**Sección 1 — Hero (pantalla completa, fondo navy con elementos decorativos cyan/naranja):**
- Logo FactorIT arriba a la izquierda
- Eyebrow: "FACTORIT · FIT"
- Título grande blanco: "Bootcamp Agentic AI con watsonx Orchestrate"
- Subtítulo crema italic: "Construye tu primer agente de IA en 4 horas"
- Sello esquina derecha: "powered by IBM watsonx Orchestrate" (logo IBM pequeño)
- Botón CTA naranja grande: "Acceder al material →" (scroll smooth a sección 4)
**Sección 2 — ¿Qué vas a construir? (3 cards horizontales, fondo crema):**
- Card 1: "Tu primer agente" — icono engranaje navy, descripción 2 líneas
- Card 2: "Multi-agente con RAG" — icono libro, descripción
- Card 3: "Reportes y APIs" — icono gráfico, descripción
**Sección 3 — El taller en números (4 stats con números grandes naranja):**
- "4h" Duración
- "6" Módulos
- "0" Líneas de código (en el módulo 1-4)
- "100%" Hands-on
**Sección 4 — Material del taller (la sección donde está el formulario de registro):**
- Título: "Descarga todo el material"
- Texto: "Registra tus datos para acceder al kit completo del bootcamp."
- Formulario con 3 campos (ver 3.3)
- Tras enviar, redirige a `/descargas?token=XXX`
**Sección 5 — Footer (fondo navy):**
- Logos FIT y watsonx
- "Material del bootcamp · taller-wox.fitlabs.dev"
- Link "github.com/leozangulo/agentic-bootcamp"
- "© 2026 FactorIT — Todos los derechos reservados"
### 3.3 Formulario de registro
**Campos:**
| Campo | Tipo | Validación | Required |
|---|---|---|---|
| `nombre` | text | min 2 chars, max 80 | ✓ |
| `email` | email | regex email válido | ✓ |
| `empresa` | text | min 2 chars, max 100 | ✓ |
| `consentimiento` | checkbox | debe estar marcado | ✓ |
Texto del checkbox: "Acepto que FactorIT use mis datos para enviarme información del bootcamp y comunicaciones futuras. No spam — solo lo importante."
**Validación cliente:** HTML5 + JS antes de submit.
**Action:** `POST /register` (ver 4.1).
### 3.4 Página de descargas `GET /descargas?token=XXX`
Validar el token (firma con `itsdangerous`, ver 4.1). Si es inválido o expirado, redirigir a `/?error=token-invalido`.
Si es válido, mostrar:
- Saludo: "¡Hola {nombre}! Acá tienes todo el material."
- Grilla 2x4 (o 3x3 según ancho) con cards de descarga, cada una con:
- Icono del tipo de archivo (PDF, Word, PPT, JSON, ZIP)
- Nombre del archivo
- Descripción breve (1 línea)
- Tamaño en MB
- Botón "Descargar" naranja
**Cards a mostrar:**
| # | Archivo | Descripción |
|---|---|---|
| 1 | `Manual_Alumno_Bootcamp_FIT.docx` | Manual paso a paso del taller (26 páginas) |
| 2 | `Bootcamp_Agentic_AI_FIT.pptx` | Slides del bootcamp (46 slides) |
| 3 | `openapi-tools-spec.json` | API principal de AskBenefits |
| 4 | `openapi-tools-spec.yaml` | Misma API en YAML (backup) |
| 5 | `dental_benefits_summary.pdf` | PDF para Knowledge Base de AskDental |
| 6 | `openapi-tools-report.json` | API de AskReporting |
| 7 | `main-desk-concierge-action.json` | Acción watsonx Assistant (opcional) |
| 8 | `bank-assistant-pack.zip` | YAMLs + tools Python del ADK |
**Bonus card (opcional, si quieres ofrecer todo en un solo download):**
- 9 | `taller-wox-archivos.zip` | Todo el material en un solo zip (28 MB)
Cada botón "Descargar" hace `GET /download/{filename}?token=XXX`.
### 3.5 Estilos críticos de CSS
- Mobile-first (breakpoint a 768px)
- Hero ocupa 100vh en desktop, 80vh en mobile
- Cards: border-radius 12px, sombra suave, hover: lift 4px + sombra más fuerte
- Botones primarios: fondo naranja `#FF7A00`, hover: `#E66A00`, border-radius 8px
- Botones secundarios: outline cyan
- Animaciones: solo fade-in al scroll (intersection observer), nada elaborado
---
## 4. Backend (FastAPI)
### 4.1 Rutas del frontend y registro
```python
# rutas que sirven HTML
GET / renderiza templates/index.html
GET /descargas valida token, renderiza templates/descargas.html
GET /static/* archivos estáticos (CSS, JS, imgs, logos)
# registro y descarga
POST /register recibe form, guarda lead en SQLite, devuelve {token, redirect_url}
GET /download/{filename} valida token query param, devuelve archivo de ./material/
```
**Lógica del token:**
- Usar `itsdangerous.URLSafeTimedSerializer` con un `SECRET_KEY` desde env.
- El token contiene el email del lead.
- Expira a las **24 horas** (configurable).
- Validar en cada descarga.
```python
from itsdangerous import URLSafeTimedSerializer
serializer = URLSafeTimedSerializer(SECRET_KEY)
token = serializer.dumps({"email": email})
# validar:
data = serializer.loads(token, max_age=86400) # 24h
```
**Esquema SQLite (`leads.db`):**
```sql
CREATE TABLE IF NOT EXISTS leads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nombre TEXT NOT NULL,
email TEXT NOT NULL,
empresa TEXT NOT NULL,
ip TEXT,
user_agent TEXT,
consent INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lead_email TEXT NOT NULL,
filename TEXT NOT NULL,
ip TEXT,
downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
Registrar cada descarga en `downloads` para tener métricas.
### 4.2 Endpoint admin (opcional, protegido con HTTP Basic)
```
GET /admin/leads → lista todos los leads (JSON, requiere basic auth con ADMIN_USER/ADMIN_PASS desde env)
GET /admin/stats → conteo de registros, descargas por archivo, top empresas
GET /admin/leads.csv → exporta leads en CSV
```
Útil para que el equipo comercial pueda hacer follow-up.
---
## 5. Los 5 endpoints del taller
Los **agentes que el alumno construye en watsonx Orchestrate** llamarán estos endpoints. Los specs OpenAPI están en `material/openapi-tools-spec.json` y `material/openapi-tools-report.json`.
### 5.1 Endpoint 1 — `POST /api/historical-procedures`
**Para qué sirve:** responde "¿qué procedimientos he tenido?", "¿cuánto pagué por X?". Es un dataset histórico filtrable.
**Request body** (JSON):
```json
{
"filters": "[{\"column\": \"member_name\", \"operator\": \"equals\", \"value\": \"Alice\"}]",
"group_by": "[\"relationship\"]"
}
```
⚠️ **Importante:** `filters` y `group_by` llegan como **strings JSON-encoded**, no como objetos directos. Decodificar con `json.loads()` dentro.
**Operadores soportados:** `equals`, `ne`, `contains`, `gt`, `lt`, `ge`, `le`.
**Dataset a servir** (CSV en `app/data/historical_procedures.csv`, ~50 filas inventadas):
Columnas exactas (todas requeridas):
| Columna | Tipo | Ejemplos |
|---|---|---|
| `member_name` | string | Alice, Bob, Charlie, Diana, Ethan |
| `relationship` | string | Self, Spouse, Mother, Father, Son, Daughter |
| `age` | int | 12, 25, 42, 65 |
| `gender` | string | Female, Male |
| `procedure` | string | Annual Physical Exam, Appendectomy, CT Scan, X Ray, Dental Cleaning, Vision Exam, MRI, Blood Test |
| `procedure_type` | string | preventive, surgery, diagnostic |
| `location` | string | City Hospital, Green Valley Clinic, Sunrise Health, Regional Medical Center |
| `date` | YYYY-MM-DD | 2024-04-28, 2023-05-02 |
| `in_network` | bool | true, false |
| `member_plan` | string | Gold PPO, Family Plan - Silver EPO, Bronze HDHP |
| `accepted_plans` | string | "Gold PPO, Medicare Advantage" |
| `cost_facility` | float | 29.4, 48.02 |
| `cost_physician` | float | 199.09, 128.9 |
| `cost_anesthesia` | float | 0.0, 2024.21 |
| `cost_medication` | float | 4.19, 5.53 |
| `total_cost` | float | 232.68, 243.3 |
| `facility_rating` | float | 4.7, 4.5 |
| `notes` | string | "Annual Physical Exam performed at City Hospital." |
**Lógica del handler:**
```python
import pandas as pd, json
df = pd.read_csv("app/data/historical_procedures.csv")
@app.post("/api/historical-procedures")
def historical_procedures(payload: dict):
filters = json.loads(payload.get("filters", "[]"))
group_by = json.loads(payload.get("group_by", "[]"))
result = df.copy()
for f in filters:
col, op, val = f["column"], f["operator"], f["value"]
if op == "equals": result = result[result[col] == val]
elif op == "ne": result = result[result[col] != val]
elif op == "contains": result = result[result[col].astype(str).str.contains(str(val), case=False, na=False)]
elif op == "gt": result = result[result[col] > val]
elif op == "lt": result = result[result[col] < val]
elif op == "ge": result = result[result[col] >= val]
elif op == "le": result = result[result[col] <= val]
if group_by:
# agregaciones por columna numérica, conteo por las demás
numeric_cols = result.select_dtypes(include='number').columns.tolist()
result = result.groupby(group_by)[numeric_cols].mean().reset_index()
return {"result": result.to_dict(orient="records")}
```
**Response (200):**
```json
{ "result": [ { "member_name": "Alice", "age": 42, ... }, ... ] }
```
### 5.2 Endpoint 2 — `POST /api/available-procedures`
**Para qué sirve:** responde "¿cuánto cuesta una radiografía?", "¿dónde puedo hacerme una resonancia magnética?". Es el catálogo de procedimientos disponibles con costos por proveedor.
**Request body:** idéntico al endpoint 1 (`filters` + `group_by`).
**Dataset** (CSV en `app/data/available_procedures.csv`, ~25-30 filas):
| Columna | Tipo | Ejemplos |
|---|---|---|
| `procedure` | string | Angioplasty, Annual Physical Exam, Appendectomy, X Ray, MRI, CT Scan |
| `location` | string | City Hospital, Regional Medical Center, Green Valley Clinic |
| `facility_rating` | float | 4.7, 4.3, 4.5 |
| `distance_miles` | float | 5.2, 12.6, 1.2 |
| `gold_ppo_plan_accepted` | bool | true, false |
| `silver_epo_plan_accepted` | bool | true, false |
| `accepted_plans` | string | "Gold PPO, Medicare Advantage" |
| `cost_facility` | float | 9432.8 |
| `cost_physician` | float | 4774.57 |
| `cost_anesthesia` | float | 1894.37 |
| `cost_medication` | float | 834.8 |
| `total_cost` | float | 16936.54 |
**Lógica:** misma función que el endpoint 1, solo cambia el CSV de entrada.
**Response (200):** `{ "result": [ ... ] }`
### 5.3 Endpoint 3 — `GET /api/member-insights`
**Para qué sirve:** responde "¿qué chequeos tengo atrasados?", "¿cuál es mi plan?", "¿cómo accedo a mi formulario 1095?". Devuelve el perfil del afiliado de demo.
**Sin parámetros.**
**Response (200):** SIEMPRE el mismo objeto. Guardarlo como `app/data/member_insights.json` y servirlo tal cual.
```json
{
"result": {
"member": {
"name": "Charlie Smith",
"date_of_birth": "2013-03-04",
"plan": "Gold PPO",
"member_id": "CS-001-2024"
},
"medical_plan": {
"name": "Gold PPO",
"deductible": 1500,
"deductible_met": 850,
"out_of_pocket_max": 6000,
"out_of_pocket_met": 1200,
"coinsurance": "20%",
"primary_care_copay": 25,
"specialist_copay": 50,
"emergency_room_copay": 250
},
"pharmacy_plan": {
"tier_1_copay": 10,
"tier_2_copay": 30,
"tier_3_copay": 60,
"tier_4_coinsurance": "30%",
"mail_order_available": true
},
"mental_health": {
"covered_visits_per_year": 20,
"visits_used": 4,
"telehealth": true,
"in_network_providers": "https://taller-wox.fitlabs.dev/docs/mental-health-providers"
},
"wellness": {
"gym_reimbursement_annual": 300,
"gym_reimbursement_used": 150,
"annual_checkup_covered": true,
"preventive_care_100_percent": true,
"flu_shot_covered": true
},
"tax_documents": {
"form_1095_available": true,
"form_1095_url": "https://taller-wox.fitlabs.dev/docs/1095-2024.pdf",
"instructions": "Tu formulario 1095 está disponible en el portal del afiliado bajo Documentos > Impuestos. Si no lo puedes acceder, llama al 1-800-FIT-CARE."
},
"overdue_procedures": [
{
"procedure": "Annual Physical Exam",
"last_date": "2022-06-15",
"recommended_frequency_months": 12,
"due_since_months": 22,
"priority": "high"
},
{
"procedure": "Dental Cleaning",
"last_date": "2023-01-10",
"recommended_frequency_months": 6,
"due_since_months": 15,
"priority": "medium"
},
{
"procedure": "Vision Exam",
"last_date": "2021-08-22",
"recommended_frequency_months": 24,
"due_since_months": 32,
"priority": "medium"
},
{
"procedure": "Blood Test - Cholesterol Panel",
"last_date": "2023-03-04",
"recommended_frequency_months": 12,
"due_since_months": 13,
"priority": "low"
}
]
}
}
```
### 5.4 Endpoint 4 — `GET /api/schedule`
**Para qué sirve:** el agente lo invoca cuando el usuario dice "agéndame una cita". No agenda nada real — devuelve una guía/instrucciones que el agente lee y reformula al usuario.
**Sin parámetros.**
**Response (200):** SIEMPRE el mismo string. Guardar el texto en `app/data/schedule_response.txt`.
```json
{
"result": "Para agendar una cita médica, sigue estos pasos: 1) Confirma con el afiliado el día y hora preferidos, y el tipo de procedimiento. 2) Verifica que el procedimiento esté cubierto por su plan (Gold PPO en este caso). 3) Llama al sistema de agendamiento de FIT Care al 1-800-FIT-CARE o entra al portal en https://taller-wox.fitlabs.dev/agenda. 4) Indica el procedimiento, el proveedor preferido (City Hospital, Green Valley Clinic, Sunrise Health o Regional Medical Center) y la fecha. 5) Confirma la cita y registra el número de confirmación. 6) Envía recordatorios automáticos al afiliado 24h y 1h antes. Recuerda: si el procedimiento requiere autorización previa, gestiónala antes de confirmar la cita."
}
```
### 5.5 Endpoint 5 — `POST /api/reports/generate-report`
**Para qué sirve:** el agente AskReporting lo llama cuando el usuario dice "create a care report". Genera un HTML compuesto, lo guarda en disco y devuelve la URL pública.
**Request body:**
```json
{
"layout_config": "[\"care_report\", {\"element_type\": \"header\", \"parameters\": {\"title\": \"End of Report\"}}]"
}
```
`layout_config` es un **string JSON-encoded** que contiene una lista de elementos. Decodificar con `json.loads()`.
Cada elemento puede ser:
**(a) Un string preset:**
- `"care_report"` — equivale a este layout completo:
```python
[
{"element_type": "header", "parameters": {"title": "Care Report"}},
{"element_type": "overview", "parameters": {
"prompt": "Summarize this email exchange in 3 bullet points: ",
"text_file": "./data/combined_email.txt",
"title": "Customer Overview"
}},
{"element_type": "claim_review_chart", "parameters": {}},
{"element_type": "table", "parameters": {
"csv_file": "./data/aetna_claim_review_summary.csv",
"title": "Claim Review Summary"
}}
]
```
**(b) Un objeto con `element_type` y `parameters`:**
| `element_type` | Parámetros | Hace |
|---|---|---|
| `header` | `title` (str) | Renderiza `<h2>{title}</h2>` centrado |
| `overview` | `prompt`, `text_file`, `title` | Lee el `.txt`, opcionalmente le aplica un prompt simple (puedes hardcodear un resumen pre-armado por archivo para evitar dependencia de LLM), renderiza como markdown convertido a HTML |
| `claim_review_chart` | (ninguno) | Renderiza un gráfico de barras Plotly con datos sintéticos de CPT codes vs charged/allowed/patient_responsibility |
| `table` | `csv_file`, `title` | Lee el CSV y lo renderiza como tabla HTML con styling Plotly |
**Archivos en `./app/data/` que tienes que crear:**
- **`combined_email.txt`** (~10 líneas): email mezclado proveedor + paciente. Ej:
```
From: dr.martinez@cityhospital.com
Subject: Follow-up after appointment
Hi Charlie,
Following our visit last week, I'm recommending a follow-up CT scan
to confirm the diagnosis. Please schedule within the next 2 weeks.
---
From: charlie.smith@gmail.com
Subject: Re: Follow-up
Thanks Dr. Martinez. Will the CT scan be covered by my Gold PPO plan?
Also, can I get a copy of the lab results from last visit?
```
- **`provider_email.txt`** (~8 líneas): email solo del proveedor con jerga médica.
```
Patient presented with bilateral lower quadrant tenderness, WBC 14k,
CRP elevated. Differential includes appendicitis vs diverticulitis.
Recommending CT abdomen/pelvis with contrast STAT.
CPT 74177 ordered. Pre-auth obtained: AUTH-2024-8821.
```
- **`aetna_email.txt`** (~8 líneas): email de aseguradora con EOB.
```
Claim #AET-2024-9912 has been processed.
Service date: 2024-04-15
Provider: City Hospital
Total billed: $1,847.50
Plan allowance: $1,200.00
Plan paid: $960.00
Patient responsibility (20% coinsurance after deductible): $240.00
```
- **`aetna_claim_review_summary.csv`** (~10 filas):
```csv
Date,CPT_Code,Description,Charged_Amount,Allowed_Amount,Plan_Paid,Patient_Responsibility
2024-04-15,74177,CT Abdomen/Pelvis w/contrast,1847.50,1200.00,960.00,240.00
2024-03-22,99213,Office visit established patient,185.00,120.00,96.00,24.00
2024-02-10,80061,Lipid panel,82.00,55.00,44.00,11.00
...
```
**Lógica del handler:**
```python
import json, uuid
from pathlib import Path
OUTPUT_DIR = Path("app/data/reports_output")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
@app.post("/api/reports/generate-report")
def generate_report(payload: dict):
layout = json.loads(payload["layout_config"])
# expandir presets
expanded = []
for item in layout:
if item == "care_report":
expanded.extend(CARE_REPORT_PRESET) # constante definida arriba
else:
expanded.append(item)
# renderizar cada elemento
html_parts = []
for el in expanded:
kind = el["element_type"]
params = el.get("parameters", {})
if kind == "header":
html_parts.append(f"<h2 style='text-align:center;'>{params['title']}</h2>")
elif kind == "overview":
html_parts.append(render_overview(params))
elif kind == "claim_review_chart":
html_parts.append(render_claim_chart())
elif kind == "table":
html_parts.append(render_table(params))
# combinar en un HTML completo (usar templates/report.html como wrapper)
report_id = uuid.uuid4().hex[:12]
output_path = OUTPUT_DIR / f"{report_id}.html"
output_path.write_text(wrap_in_template("\n".join(html_parts)))
public_url = f"https://taller-wox.fitlabs.dev/api/reports/output/{report_id}.html"
return {"public_url": public_url}
# servir los HTMLs generados como estáticos
app.mount("/api/reports/output", StaticFiles(directory="app/data/reports_output"), name="reports")
```
**Response (200):**
```json
{ "public_url": "https://taller-wox.fitlabs.dev/api/reports/output/abc123def456.html" }
```
**Response (500) si algo falla:**
```json
{ "detail": "Error generating report: {mensaje}" }
```
**Template `templates/report.html`** (wrapper del reporte generado):
```html
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Care Report</title>
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
<style>
body { font-family: Inter, sans-serif; max-width: 960px; margin: 40px auto; padding: 20px; color: #1A1A1A; }
h2 { color: #0A1F44; border-bottom: 3px solid #FF7A00; padding-bottom: 8px; }
table { width: 100%; border-collapse: collapse; margin: 16px 0; }
th { background: #0A1F44; color: white; padding: 10px; }
td { padding: 8px; border-bottom: 1px solid #D8DEE5; }
</style>
</head>
<body>
<header>
<p style="color:#00B5D8; font-weight:700;">FACTORIT · FIT</p>
</header>
{{ content | safe }}
<footer style="margin-top:40px; color:#5A6473; font-size:13px;">
Generado por AskReporting · taller-wox.fitlabs.dev
</footer>
</body>
</html>
```
---
## 6. Configuración por variables de entorno
```bash
# .env.example
SECRET_KEY=cambiar-esto-en-prod-string-largo-aleatorio
ADMIN_USER=admin
ADMIN_PASS=cambiar-esto
TOKEN_EXPIRY_HOURS=24
BASE_URL=https://taller-wox.fitlabs.dev
# Opcional, para mandar email de confirmación al registrarse
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=bootcamp@fitlabs.dev
```
---
## 7. Deployment
### 7.1 Dockerfile
```dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
COPY static/ ./static/
COPY material/ ./material/
# crear directorios runtime
RUN mkdir -p app/data/reports_output
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
```
### 7.2 DNS
Apuntar `taller-wox.fitlabs.dev` (registro A o CNAME) al hosting elegido (Railway/Render/etc.).
### 7.3 HTTPS
Obligatorio. Usar el SSL automático del proveedor o Let's Encrypt si es VPS propio. watsonx Orchestrate **rechaza** llamar HTTPS con certificados inválidos.
### 7.4 CORS
Los endpoints `/api/*` deben permitir requests desde `https://*.ibm.com` (donde corre watsonx Orchestrate). Configurar:
```python
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # o más restrictivo: ["https://us-south.dl.watson-orchestrate.cloud.ibm.com"]
allow_credentials=True,
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
```
---
## 8. Testing manual antes de publicar
Una vez todo desplegado, verificar:
```bash
# Frontend
curl https://taller-wox.fitlabs.dev/ # debe devolver HTML del landing
curl -X POST https://taller-wox.fitlabs.dev/register \
-d "nombre=Test&email=test@test.com&empresa=ACME&consentimiento=on"
# debería devolver {"token":"...","redirect_url":"/descargas?token=..."}
# Descargas (con token válido)
curl -I "https://taller-wox.fitlabs.dev/download/Manual_Alumno_Bootcamp_FIT.docx?token=XXX"
# debe devolver 200 con Content-Type docx
# Endpoints del taller
curl -X POST https://taller-wox.fitlabs.dev/api/historical-procedures \
-H "Content-Type: application/json" \
-d '{"filters":"[]","group_by":"[]"}'
# debe devolver {"result":[...]}
curl https://taller-wox.fitlabs.dev/api/member-insights
# debe devolver el objeto con member, plan, overdue_procedures
curl https://taller-wox.fitlabs.dev/api/schedule
# debe devolver el texto de scheduling
curl -X POST https://taller-wox.fitlabs.dev/api/reports/generate-report \
-H "Content-Type: application/json" \
-d '{"layout_config":"[\"care_report\"]"}'
# debe devolver {"public_url":"..."}
```
**Test end-to-end del taller:**
1. Crear cuenta IBM Cloud trial.
2. Importar `openapi-tools-spec.json` en un agente nuevo en watsonx Orchestrate.
3. Hacer prompt: "¿Estoy atrasado en algún chequeo?" — debe llamar `/api/member-insights` y devolver los 4 procedimientos overdue.
4. Hacer prompt: "¿Cuánto cuesta una resonancia magnética?" — debe llamar `/api/available-procedures` con filtro `procedure contains MRI`.
---
## 9. Datos sintéticos — orientación
Los datasets CSV los inventas pero tienen que ser **coherentes entre sí**:
- En `historical_procedures.csv`, el afiliado principal (`member_name = Charlie Smith`) debería tener filas de `Annual Physical Exam`, `Dental Cleaning`, `Vision Exam` y `Blood Test` con `date` consistente con los `last_date` de `member_insights.json` (los procedimientos overdue).
- En `available_procedures.csv`, los `procedure` deben incluir al menos los procedimientos preguntables: `X Ray`, `MRI`, `CT Scan`, `Annual Physical Exam`, `Appendectomy`, `Dental Cleaning`, `Vision Exam`.
- Los costos en ambos datasets pueden diferir (uno es histórico, otro es catálogo de oferta).
**Importante:** los costos deben verse realistas. Una radiografía cuesta ~$80-300, una resonancia ~$1500-3000, una apendicectomía ~$10000-30000.
---
## 10. Logos y branding
**Logos a conseguir (Felipe los provee al desarrollador):**
1. `static/img/logo-factorit.svg` — logo oficial FactorIT (Felipe ya lo tiene)
2. `static/img/logo-watsonx.svg` — logo "IBM watsonx Orchestrate" oficial (bajar de https://www.ibm.com/brand/ o usar texto estilizado mientras tanto)
**Favicon:**
- `static/img/favicon.ico` y `favicon-32x32.png` — versión simplificada del logo FIT
**Meta tags para social sharing (OpenGraph):**
```html
<meta property="og:title" content="Bootcamp Agentic AI — watsonx Orchestrate | FactorIT">
<meta property="og:description" content="Construye tu primer agente de IA en 4 horas con IBM watsonx Orchestrate. Material completo del bootcamp para descarga.">
<meta property="og:image" content="https://taller-wox.fitlabs.dev/static/img/og-image.png">
<meta property="og:url" content="https://taller-wox.fitlabs.dev/">
```
---
## 11. Checklist de entrega para Claude Code
Cuando Claude Code termine, debe poder confirmar:
- [ ] El comando `uvicorn app.main:app --reload` levanta el server local en `http://localhost:8000`
- [ ] `GET /` muestra el landing brandeado FIT con formulario
- [ ] El formulario valida los 3 campos + checkbox y guarda en `leads.db`
- [ ] Tras submit redirige a `/descargas?token=...`
- [ ] La página de descargas muestra las 8-9 cards de archivos
- [ ] Cada botón "Descargar" baja el archivo correcto desde `material/`
- [ ] Los 5 endpoints responden correctamente con curl (ver sección 8)
- [ ] `GET /admin/leads` requiere basic auth y lista los registros
- [ ] El `Dockerfile` builda sin errores: `docker build -t taller-wox .`
- [ ] El container corre: `docker run -p 8000:8000 -v $(pwd)/leads.db:/app/leads.db taller-wox`
---
## 12. Lo que NO está en el alcance (para evitar scope creep)
- ❌ Editor visual de leads — solo lectura JSON/CSV.
- ❌ Recuperación de password — no hay password, solo email para descarga.
- ❌ Email transaccional obligatorio — opcional (SMTP en env).
- ❌ Analytics avanzados — solo conteos simples en `/admin/stats`.
- ❌ Multi-idioma — solo español.
- ❌ Re-captcha — basta con honeypot field invisible en el formulario.
---
## 13. Resumen ejecutivo para arrancar
**Lo primero que Claude Code debe hacer:**
1. Crear la estructura de carpetas (sección 2).
2. Implementar `app/main.py` con FastAPI básico, mount de static y templates Jinja2.
3. Implementar registro + descarga (sección 4) — eso es el corazón del producto.
4. Implementar los 5 endpoints (sección 5) — con datasets sintéticos pequeños primero.
5. Implementar el landing HTML con branding (sección 3).
6. Implementar la página de descargas (sección 3.4).
7. Dockerizar (sección 7.1).
8. Smoke tests (sección 8).
9. Deploy a un proveedor (a definir por Felipe).
10. Apuntar el DNS `taller-wox.fitlabs.dev` al deployment.
**Tiempo estimado total para un desarrollador senior:** 1-1.5 días de trabajo enfocado.
---
**Fin del SPEC. Cualquier ambigüedad → preguntar a Felipe antes de implementar.**

0
app/__init__.py Normal file
View File

42
app/admin.py Normal file
View File

@@ -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()

84
app/benefits_api.py Normal file
View File

@@ -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}

21
app/config.py Normal file
View File

@@ -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()

View File

@@ -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
1 Date CPT_Code Description Charged_Amount Allowed_Amount Plan_Paid Patient_Responsibility
2 2024-04-15 74177 CT Abdomen/Pelvis w/contrast 1847.50 1200.00 960.00 240.00
3 2024-03-22 99213 Office visit established patient 185.00 120.00 96.00 24.00
4 2024-02-10 80061 Lipid panel 82.00 55.00 44.00 11.00
5 2024-01-18 99395 Preventive visit adult 310.00 250.00 250.00 0.00
6 2023-12-05 90686 Influenza vaccine 55.00 40.00 40.00 0.00
7 2023-11-12 87880 Strep A direct test 42.00 28.00 22.40 5.60
8 2023-10-08 71046 Chest X-ray 2 views 210.00 140.00 112.00 28.00
9 2023-09-22 93000 Electrocardiogram complete 138.00 90.00 72.00 18.00
10 2023-08-14 99214 Office visit established mod complexity 250.00 170.00 136.00 34.00
11 2023-07-03 36415 Venipuncture 18.00 12.00 9.60 2.40

8
app/data/aetna_email.txt Normal file
View File

@@ -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.

View File

@@ -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
1 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
2 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
3 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
4 MRI Green Valley Clinic 4.5 8.4 true false Gold PPO, Bronze HDHP 1520.00 860.00 0.00 12.50 2392.50
5 MRI Sunrise Health 4.3 15.1 true false Gold PPO, Bronze HDHP 1610.00 880.00 0.00 13.00 2503.00
6 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
7 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
8 CT Scan Sunrise Health 4.3 15.1 true false Gold PPO, Bronze HDHP 1750.00 820.00 0.00 9.00 2579.00
9 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
10 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
11 X Ray Sunrise Health 4.3 15.1 true false Gold PPO, Bronze HDHP 90.00 120.00 0.00 1.50 211.50
12 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
13 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
14 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
15 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
16 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
17 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
18 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
19 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
20 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
21 Dental Cleaning Sunrise Health 4.3 15.1 true false Gold PPO, Bronze HDHP 55.00 115.00 0.00 0.00 170.00
22 Vision Exam Sunrise Health 4.3 15.1 true false Gold PPO, Bronze HDHP 35.00 105.00 0.00 0.00 140.00
23 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
24 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
25 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
26 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
27 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
28 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
29 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

View File

@@ -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

View File

@@ -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."
1 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
2 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.
3 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.
4 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.
5 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.
6 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.
7 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.
8 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.
9 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.
10 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.
11 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.
12 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.
13 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.
14 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.
15 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.
16 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.
17 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.
18 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.
19 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.
20 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.
21 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.
22 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.
23 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.
24 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.
25 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.
26 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.
27 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.
28 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.
29 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.
30 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.
31 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.
32 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.
33 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.
34 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.
35 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.
36 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.
37 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.
38 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.
39 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.
40 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.
41 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.
42 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.
43 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.
44 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.
45 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.
46 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.
47 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.
48 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.
49 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.
50 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.
51 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.

View File

@@ -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"
}
]
}
}

View File

@@ -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.

View File

View File

@@ -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.

128
app/db.py Normal file
View File

@@ -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,
}

136
app/frontend.py Normal file
View File

@@ -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",
)

47
app/main.py Normal file
View File

@@ -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}

129
app/reports_api.py Normal file
View File

@@ -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"<h2 style='text-align:center;'>{title}</h2>"
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"<p>{line}</p>" for line in text.splitlines() if line.strip())
return f"<h2>{title}</h2><div style='background:#F5F7FA;padding:16px;border-radius:8px;'>{paragraphs}</div>"
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"<h2>{title}</h2>" + 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}"}

41
app/security.py Normal file
View File

@@ -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

36
app/templates/base.html Normal file
View File

@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Bootcamp Agentic AI — watsonx Orchestrate | FactorIT{% endblock %}</title>
<meta name="description" content="Construye tu primer agente de IA en 4 horas con IBM watsonx Orchestrate. Material completo del bootcamp para descarga.">
<meta property="og:title" content="Bootcamp Agentic AI — watsonx Orchestrate | FactorIT">
<meta property="og:description" content="Construye tu primer agente de IA en 4 horas con IBM watsonx Orchestrate.">
<meta property="og:url" content="https://taller-wox.fitlabs.dev/">
<link rel="icon" href="/static/img/LogoFIT.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/styles.css">
</head>
<body>
<header class="site-header">
<a class="brand" href="/"><img src="/static/img/LogoFIT.png" alt="FactorIT" height="36"></a>
<span class="powered-by">powered by <strong>IBM watsonx Orchestrate</strong></span>
</header>
{% block content %}{% endblock %}
<footer class="site-footer">
<div class="footer-inner">
<p><strong>FactorIT · FIT</strong> — Material del bootcamp · taller-wox.fitlabs.dev</p>
<p class="copyright">© 2026 FactorIT — Todos los derechos reservados</p>
</div>
</footer>
<script src="/static/js/app.js" defer></script>
</body>
</html>

View File

@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Material del bootcamp — {{ nombre }}{% endblock %}
{% block content %}
<section class="descargas-section">
<h1 class="hello">¡Hola {{ nombre }}!</h1>
<p class="hello-sub">Acá tienes todo el material del bootcamp.</p>
<div class="download-cards">
{% for d in downloads %}
<article class="download-card">
<div class="dl-icon">{{ d.icon }}</div>
<h2>{{ d.title }}</h2>
<p>{{ d.description }}</p>
<p class="size">{% if d.available %}{{ d.size_mb }} MB{% else %}Próximamente{% endif %}</p>
{% if d.available %}
<a class="btn btn-primary" href="/download/{{ d.filename }}?token={{ token }}">Descargar</a>
{% else %}
<span class="btn btn-disabled" title="Archivo aún no disponible">No disponible</span>
{% endif %}
</article>
{% endfor %}
</div>
</section>
{% endblock %}

63
app/templates/index.html Normal file
View File

@@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block content %}
{% if error %}
<div class="banner banner-error" role="alert">{{ error }}</div>
{% endif %}
<section class="hero">
<div class="hero-inner">
<p class="eyebrow">FACTORIT · FIT</p>
<h1>Bootcamp Agentic AI con watsonx Orchestrate</h1>
<p class="subtitle">Construye tu primer agente de IA en 4 horas.</p>
<a class="btn btn-primary" href="#descargas">Acceder al material →</a>
</div>
</section>
<section class="cards-section">
<h2 class="section-title">¿Qué vas a construir?</h2>
<div class="cards">
<article class="card"><div class="icon">⚙️</div><h3>Tu primer agente</h3><p>Conecta una API real a un agente conversacional sin escribir código.</p></article>
<article class="card"><div class="icon">📚</div><h3>Multi-agente con RAG</h3><p>Compón agentes especializados con base de conocimiento documental.</p></article>
<article class="card"><div class="icon">📊</div><h3>Reportes y APIs</h3><p>Genera reportes ejecutivos invocando endpoints en vivo.</p></article>
</div>
</section>
<section class="stats-section">
<h2 class="section-title">El taller en números</h2>
<div class="stats">
<div class="stat"><span class="num">4h</span><span class="label">Duración</span></div>
<div class="stat"><span class="num">6</span><span class="label">Módulos</span></div>
<div class="stat"><span class="num">0</span><span class="label">Líneas de código</span></div>
<div class="stat"><span class="num">100%</span><span class="label">Hands-on</span></div>
</div>
</section>
<section id="descargas" class="form-section">
<h2 class="section-title">Descarga todo el material</h2>
<p class="form-intro">Registra tus datos para acceder al kit completo del bootcamp.</p>
<form class="register-form" method="post" action="/register" autocomplete="on">
<div class="field">
<label for="nombre">Nombre completo</label>
<input type="text" id="nombre" name="nombre" minlength="2" maxlength="80" required>
</div>
<div class="field">
<label for="email">Email corporativo</label>
<input type="email" id="email" name="email" required>
</div>
<div class="field">
<label for="empresa">Empresa</label>
<input type="text" id="empresa" name="empresa" minlength="2" maxlength="100" required>
</div>
<div class="field field-checkbox">
<input type="checkbox" id="consentimiento" name="consentimiento" required>
<label for="consentimiento">Acepto que FactorIT use mis datos para enviarme información del bootcamp y comunicaciones futuras. No spam — solo lo importante.</label>
</div>
<div class="hp-field" aria-hidden="true">
<label for="website">Website</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
</div>
<button type="submit" class="btn btn-primary btn-large">Acceder al material</button>
</form>
</section>
{% endblock %}

21
app/templates/report.html Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Care Report</title>
<style>
body { font-family: 'Inter', system-ui, sans-serif; max-width: 960px; margin: 40px auto; padding: 20px; color: #1A1A1A; }
h2 { color: #0A1F44; border-bottom: 3px solid #FF7A00; padding-bottom: 8px; }
table { width: 100%; border-collapse: collapse; margin: 16px 0; }
th { background: #0A1F44; color: white; padding: 10px; text-align: left; }
td { padding: 8px; border-bottom: 1px solid #D8DEE5; }
.eyebrow { color: #00B5D8; font-weight: 700; }
footer { margin-top: 40px; color: #5A6473; font-size: 13px; }
</style>
</head>
<body>
<header><p class="eyebrow">FACTORIT · FIT</p></header>
{{ content | safe }}
<footer>Generado por AskReporting · taller-wox.fitlabs.dev</footer>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -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>`.
**Token:** `itsdangerous.URLSafeTimedSerializer(SECRET_KEY).dumps({"email": email, "nombre": nombre})`, expira 24h (`TOKEN_EXPIRY_HOURS` env).
### 3.3 Página de descargas (`GET /descargas?token=...`)
Valida token. Si inválido o expirado → redirige a `/?error=token-invalido` y la home muestra banner "El link expiró, regístrate de nuevo".
Si válido, renderiza:
- Saludo "¡Hola {nombre}!"
- **2 cards grandes** (cambio importante vs SPEC §3.4 que tenía 8-9 cards):
1. **🧩 Material técnico** → `taller-wox-tecnico.zip`
Descripción: "Specs OpenAPI, configs y artefactos para importar a watsonx Orchestrate"
2. **📚 Material funcional** → `taller-wox-funcional.zip`
Descripción: "Manual paso a paso del bootcamp y deck de slides"
Cada card: icono, título, descripción 1 línea, tamaño en MB (calculado dinámicamente al servir la página), botón naranja "Descargar".
**Cards hardcodeadas en el template** (template Jinja2 + lista Python en `app/frontend.py`). Si en el futuro se quiere editar copy sin redeploy, pasar a `app/data/downloads.json`.
**Contenido de los ZIPs:** **Felipe los arma y los sube** vía SFTP / file manager de Coolify directo al volumen `/app/material/`. El backend no decide, no genera, no transforma — solo sirve lo que esté en esa carpeta.
### 3.4 Descarga (`GET /download/{filename}?token=...`)
- Valida token query param. Si inválido → 401.
- Verifica que `filename` esté en una **whitelist hardcodeada** (`{"taller-wox-tecnico.zip", "taller-wox-funcional.zip"}`) para prevenir path traversal.
- Sirve el archivo desde `/app/material/{filename}` con `Content-Disposition: attachment`.
- Registra la descarga en la tabla `downloads` (lead_email, filename, ip, downloaded_at) — útil para `/admin/stats`.
### 3.5 API del taller (los 5 endpoints)
Todos los endpoints del SPEC §5 se mantienen **exactamente** como están especificados (mismo contrato, mismo schema, mismas respuestas). Esta sección solo enumera el alcance y enlaza al SPEC para el detalle.
| # | Endpoint | Tipo | Detalle |
|---|---|---|---|
| 1 | `POST /api/historical-procedures` | Filtros + group_by sobre CSV histórico | SPEC §5.1 |
| 2 | `POST /api/available-procedures` | Filtros + group_by sobre CSV catálogo | SPEC §5.2 |
| 3 | `GET /api/member-insights` | JSON estático del afiliado demo | SPEC §5.3 |
| 4 | `GET /api/schedule` | Guía estática de scheduling | SPEC §5.4 |
| 5 | `POST /api/reports/generate-report` | Genera HTML con Jinja2 + Plotly, guarda en disco, devuelve URL pública | SPEC §5.5 |
**CORS:** `allow_origins=["*"]`, `allow_methods=["GET", "POST"]` — necesario para que los agentes de watsonx Orchestrate puedan llamar los endpoints desde `*.cloud.ibm.com`.
**Sin** rate limiting, **sin** cleanup automático de los HTMLs generados (volumen esperado: ~150 archivos × 30 KB = 5 MB por taller, despreciable; si crece, se limpia con un cron manual o `rm` puntual).
**Datasets sintéticos** (los genero yo siguiendo el SPEC §9): ver sección 6 de este doc.
### 3.6 Admin básico
3 endpoints, todos detrás de HTTP Basic auth (`ADMIN_USER` + `ADMIN_PASS` desde env):
- `GET /admin/leads.json` — lista paginada de leads (`?limit=100&offset=0`)
- `GET /admin/leads.csv` — exporta todo como CSV descargable (Excel-friendly, UTF-8 con BOM para que Excel respete acentos)
- `GET /admin/stats` — JSON con: `total_leads`, `total_downloads`, `downloads_por_archivo`, `top_5_empresas`
Sin UI, sin gráficos, sin filtros. JSON crudo + CSV download.
---
## 4. Datos y persistencia
### 4.1 Esquema SQLite
```sql
CREATE TABLE IF NOT EXISTS leads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nombre TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
empresa TEXT NOT NULL,
ip TEXT,
user_agent TEXT,
consent INTEGER NOT NULL DEFAULT 0,
times_registered INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lead_email TEXT NOT NULL,
filename TEXT NOT NULL,
ip TEXT,
downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_downloads_email ON downloads(lead_email);
CREATE INDEX IF NOT EXISTS idx_downloads_filename ON downloads(filename);
```
**Cambio vs SPEC §4.1:** agregué `UNIQUE` en `email`, `times_registered`, `last_seen` para soportar la lógica de duplicados decidida en brainstorming.
### 4.2 Lo que va en disco persistente
| Path en container | Qué contiene | Volumen Coolify |
|---|---|---|
| `/app/leads.db` | SQLite con `leads` y `downloads` | sí |
| `/app/app/data/reports_output/` | HTMLs generados por endpoint 5 | sí |
| `/app/material/` | Los 2 ZIPs que sube Felipe | sí |
### 4.3 Lo que va dentro del container (no persistente, viaja con la imagen)
- Código de la app (`/app/app/`)
- Datasets sintéticos de los endpoints (`/app/app/data/*.csv`, `*.json`, `*.txt`) — son inmutables, cambios pasan por commit + redeploy
- Static files (`/app/static/`) — CSS, JS, imágenes
- Templates Jinja2 (`/app/app/templates/`)
---
## 5. Deploy
### 5.1 Dockerfile
```dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
COPY static/ ./static/
# crear directorios runtime (se montan como volúmenes en prod)
RUN mkdir -p app/data/reports_output material
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
```
Sin healthcheck por simplicidad (Coolify pinguea HTTP `/` por defecto). Sin multi-stage build — la imagen final pesa ~250 MB y no vale la pena optimizar para 48h.
### 5.2 Configuración Coolify
**Volúmenes persistentes a declarar en la UI:**
| Mount point | Tipo |
|---|---|
| `/app/leads.db` | bind file |
| `/app/app/data/reports_output/` | bind dir |
| `/app/material/` | bind dir |
**Variables de entorno:**
```
SECRET_KEY=<random 64-char string>
ADMIN_USER=felipe
ADMIN_PASS=<random largo>
TOKEN_EXPIRY_HOURS=24
BASE_URL=https://taller-wox.fitlabs.dev
```
**DNS:** apuntar `taller-wox.fitlabs.dev` al IP del VPS de Coolify desde el panel de DNS del registrar. Coolify gestiona el cert Let's Encrypt automáticamente.
### 5.3 Workflow de actualización
- **Cambios de código** (endpoints, frontend, templates): commit + push → Coolify rebuildea + redeploy.
- **Cambios de los ZIPs descargables**: Felipe sube directo al volumen `/app/material/` vía SFTP o file manager de Coolify — **sin redeploy**.
- **Cambios de copy de las cards de descarga**: requieren redeploy (están hardcodeadas). Aceptable por ahora.
---
## 6. Datasets sintéticos que tengo que generar
Todos siguiendo los esquemas del SPEC §5 y §9. **Coherencia entre datasets es crítica.**
### 6.1 `app/data/historical_procedures.csv` (~50 filas)
Columnas exactas del SPEC §5.1. **Reglas de coherencia:**
- Al menos 4 filas con `member_name = Charlie Smith` y `relationship = Self`, con procedimientos y `date` consistentes con los `last_date` de `member_insights.json`:
- `Annual Physical Exam` con `date = 2022-06-15`
- `Dental Cleaning` con `date = 2023-01-10`
- `Vision Exam` con `date = 2021-08-22`
- `Blood Test - Cholesterol Panel` con `date = 2023-03-04`
- Resto de filas: otros member_names (Alice, Bob, Diana, Ethan) con relationships variados (Self, Spouse, Mother, Father, Son, Daughter), con procedimientos variados que cubren `preventive`, `surgery`, `diagnostic`.
- Costos realistas: X Ray $80-300, MRI $1500-3000, Annual Physical $200-400, Appendectomy $10k-30k, Dental Cleaning $80-200.
- `in_network` mezcla de true/false (~70/30).
- `member_plan` mezcla de los 3 planes del SPEC.
### 6.2 `app/data/available_procedures.csv` (~25-30 filas)
Columnas del SPEC §5.2. **Reglas:**
- Cubrir al menos estos `procedure`: `X Ray`, `MRI`, `CT Scan`, `Annual Physical Exam`, `Appendectomy`, `Dental Cleaning`, `Vision Exam`, `Blood Test`, `Angioplasty`, `Ultrasound`.
- Cada procedimiento aparece en 2-4 ubicaciones distintas (City Hospital, Regional Medical Center, Green Valley Clinic, Sunrise Health) con costos y distancias distintas.
- `gold_ppo_plan_accepted` true en ~80% de las filas (es el plan de Charlie Smith).
- Costos realistas y diferentes a los de `historical_procedures.csv` (uno es histórico, otro es catálogo).
### 6.3 `app/data/member_insights.json`
JSON estático basado en el SPEC §5.3, **con un ajuste obligatorio**: las fechas de `overdue_procedures` del SPEC están escritas con base ~2024 (ej. `last_date: "2022-06-15"` con `due_since_months: 22`). Hoy es 2026-05-12, así que esos cálculos están desfasados ~2 años. Hay que **re-calibrar** `last_date` y `due_since_months` para que sean coherentes con la fecha actual y el `recommended_frequency_months` de cada item. Las fechas resultantes también deben calzar con las filas correspondientes en `historical_procedures.csv` (sección 6.1).
Ejemplo de re-calibración para `Annual Physical Exam` (frecuencia recomendada: 12 meses, prioridad alta):
- `last_date`: ~24 meses atrás → `2024-05-15`
- `due_since_months`: 12 (debido hace 1 año)
### 6.4 `app/data/schedule_response.txt`
Texto estático tal cual el SPEC §5.4.
### 6.5 Archivos para endpoint 5 (reportes)
- `app/data/combined_email.txt` — email mezclado proveedor + paciente (ejemplo en SPEC §5.5)
- `app/data/provider_email.txt` — email solo del proveedor con jerga médica
- `app/data/aetna_email.txt` — email de aseguradora con EOB
- `app/data/aetna_claim_review_summary.csv` (~10 filas) — CPT codes con charged/allowed/plan_paid/patient_responsibility coherentes (el último iguala los anteriores menos los descuentos)
---
## 7. Estructura de carpetas final
```
taller-wox/
├── app/
│ ├── main.py # FastAPI app, mounts, CORS, lifespan
│ ├── frontend.py # GET /, GET /descargas, POST /register, GET /download
│ ├── benefits_api.py # endpoints 1-4
│ ├── reports_api.py # endpoint 5
│ ├── admin.py # endpoints /admin/*
│ ├── db.py # SQLite, init schema, helpers
│ ├── security.py # token sign/verify, basic auth, honeypot
│ ├── data/
│ │ ├── historical_procedures.csv
│ │ ├── available_procedures.csv
│ │ ├── member_insights.json
│ │ ├── schedule_response.txt
│ │ ├── combined_email.txt
│ │ ├── provider_email.txt
│ │ ├── aetna_email.txt
│ │ ├── aetna_claim_review_summary.csv
│ │ └── reports_output/ # generados por endpoint 5 (volumen persistente)
│ └── templates/
│ ├── index.html # landing + registro
│ ├── descargas.html # página post-registro
│ └── report.html # wrapper del reporte (endpoint 5)
├── static/
│ ├── css/styles.css
│ ├── img/
│ │ ├── LogoFIT.png # ya existe, comprimir
│ │ └── favicon.ico # generar
│ └── js/app.js
├── material/ # volumen persistente, Felipe sube los ZIPs
│ ├── taller-wox-tecnico.zip
│ └── taller-wox-funcional.zip
├── docs/
│ └── superpowers/
│ └── specs/
│ └── 2026-05-12-taller-wox-design.md # este archivo
├── leads.db # volumen persistente
├── Dockerfile
├── requirements.txt
├── .env.example
└── README.md
```
---
## 8. Cuts explícitos (lo que NO se hace para el jueves)
- ❌ Email transaccional (SMTP)
- ❌ Admin UI / dashboard con gráficos
- ❌ ZIP "todo en uno" como tercera descarga
- ❌ ZIP-por-archivo individual
- ❌ Re-captcha (solo honeypot)
- ❌ Stats avanzados / analytics
- ❌ Multi-idioma (solo español)
- ❌ Editor visual de leads
- ❌ Rate limiting en `/api/*`
- ❌ Cleanup automático de reportes generados
- ❌ Conversión PPTX → PDF (Felipe arma los ZIPs)
- ❌ Logo IBM watsonx oficial (placeholder de texto hasta tener el oficial)
- ❌ Multi-stage Docker build
- ❌ Healthcheck custom (Coolify usa ping HTTP por defecto)
- ❌ Tests automatizados extensos (solo smoke tests manuales del SPEC §8)
---
## 9. Criterios de aceptación para el jueves 2026-05-14
Antes del taller, todo lo siguiente debe estar verde:
**Smoke tests manuales:**
- [ ] `GET https://taller-wox.fitlabs.dev/` devuelve 200 con HTML brandeado FIT
- [ ] Submit del form con datos válidos redirige a `/descargas?token=...`
- [ ] La página de descargas muestra las 2 cards y el saludo con el nombre
- [ ] `GET /download/taller-wox-tecnico.zip?token=...` descarga el ZIP correcto
- [ ] `GET /download/taller-wox-funcional.zip?token=...` descarga el ZIP correcto
- [ ] Token inválido → redirige a home con banner de error
- [ ] Email duplicado en form → re-emite token sin error
**API del taller (con `curl` desde laptop):**
- [ ] `POST /api/historical-procedures` con `{"filters":"[]","group_by":"[]"}` devuelve filas
- [ ] `POST /api/historical-procedures` con filtro `member_name = Charlie Smith` devuelve las 4 filas esperadas
- [ ] `POST /api/available-procedures` con filtro `procedure contains MRI` devuelve resultados
- [ ] `GET /api/member-insights` devuelve el objeto con `overdue_procedures` con 4 items
- [ ] `GET /api/schedule` devuelve el texto de scheduling
- [ ] `POST /api/reports/generate-report` con `{"layout_config":"[\"care_report\"]"}` devuelve un `public_url` que abre un HTML válido
**End-to-end con watsonx Orchestrate:**
- [ ] Importar `openapi-tools-spec.json` en un agente nuevo de watsonx funciona sin errores de TLS
- [ ] Prompt "¿Estoy atrasado en algún chequeo?" devuelve los 4 procedimientos overdue
- [ ] Prompt "¿Cuánto cuesta una resonancia magnética?" devuelve resultados de MRI
**Admin:**
- [ ] `GET /admin/leads.csv` con basic auth descarga CSV con los leads de prueba
- [ ] `GET /admin/stats` devuelve los conteos esperados
**Operacional:**
- [ ] Coolify rebuildea sin errores al hacer push
- [ ] Redeploy NO borra `leads.db` ni los ZIPs (volúmenes funcionando)
- [ ] HTTPS válido (curl al dominio sin warning de cert)
---
## 10. Riesgos identificados
| Riesgo | Mitigación |
|---|---|
| Cambio de DNS no propaga a tiempo | Configurar DNS lo antes posible (hoy mismo idealmente), TTL bajo |
| Volúmenes Coolify mal configurados → pérdida de datos en redeploy | Probar un redeploy de prueba antes del miércoles y verificar que `leads.db` sobrevive |
| Watsonx no puede llamar a la API por problema de TLS o CORS | Smoke test end-to-end con un agente de prueba el miércoles |
| Datasets sintéticos inconsistentes (Charlie Smith con datos que no calzan) | Validar cruzando `historical_procedures.csv` vs `member_insights.json` antes de deploy |
| Tamaño del PNG del logo (971 KB) impacta carga del landing | Comprimir a <100 KB con tinypng o similar antes del deploy |
| Bug en código del frontend tumba la API en vivo durante el taller | Política: no tocar código el día del taller. Si hay bug crítico en API, rollback inmediato a la versión anterior en Coolify |
---
## 11. Lo que NO está en este diseño y necesita decidirse después del jueves
- Persistencia de los HTMLs generados por endpoint 5 — política de retención (¿borrar después de N días?)
- Si vienen cohortes con datasets distintos, cómo soportar multi-cohort sin redeploy
- Si el admin necesita UI real (dashboard, gráficos)
- Si conviene separar API y frontend en dos servicios
- Si vale la pena agregar webhook a Slack/email cuando se registra un lead nuevo (lead capture activo)
---
**Fin del diseño. Siguiente paso:** invocar skill `writing-plans` para crear el plan de implementación paso a paso.

BIN
images/LogoFIT.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 949 KiB

0
material/.gitkeep Normal file
View File

91
material/LEEME.txt Normal file
View File

@@ -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

Binary file not shown.

View File

@@ -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"
}
]
}

View File

@@ -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 = \"<USERNAME>\"\n",
"except KeyError:\n",
" esuser = input(\"Please enter your Elasticsearch user name (hit enter): \")\n",
"try:\n",
" espassword = \"<PASSWORD>\"\n",
"except KeyError:\n",
" espassword = getpass.getpass(\"Please enter your Elasticsearch password (hit enter): \")\n",
"try:\n",
" eshost = \"<ELASTIC_URL>\"\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 2>/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": [
"<div>\n",
"<style scoped>\n",
" .dataframe tbody tr th:only-of-type {\n",
" vertical-align: middle;\n",
" }\n",
"\n",
" .dataframe tbody tr th {\n",
" vertical-align: top;\n",
" }\n",
"\n",
" .dataframe thead th {\n",
" text-align: right;\n",
" }\n",
"</style>\n",
"<table border=\"1\" class=\"dataframe\">\n",
" <thead>\n",
" <tr style=\"text-align: right;\">\n",
" <th></th>\n",
" <th>resumen</th>\n",
" <th>Compositor</th>\n",
" <th>Pregunta</th>\n",
" <th>Respuesta</th>\n",
" </tr>\n",
" </thead>\n",
" <tbody>\n",
" <tr>\n",
" <th>0</th>\n",
" <td>Tema: Mozart, Pregunta: ¿Dónde nació Mozart?, ...</td>\n",
" <td>Mozart</td>\n",
" <td>¿Dónde nació Mozart?</td>\n",
" <td>Mozart nació en Salzburgo, que en su época era...</td>\n",
" </tr>\n",
" <tr>\n",
" <th>1</th>\n",
" <td>Tema: Mozart, Pregunta: ¿A qué edad empezó a c...</td>\n",
" <td>Mozart</td>\n",
" <td>¿A qué edad empezó a componer?</td>\n",
" <td>Comenzó a componer desde los 5 años, escribien...</td>\n",
" </tr>\n",
" <tr>\n",
" <th>2</th>\n",
" <td>Tema: Mozart, Pregunta: ¿Cuál es su nombre com...</td>\n",
" <td>Mozart</td>\n",
" <td>¿Cuál es su nombre completo?</td>\n",
" <td>Su nombre completo era Johannes Chrysostomus W...</td>\n",
" </tr>\n",
" <tr>\n",
" <th>3</th>\n",
" <td>Tema: Mozart, Pregunta: ¿Qué significa “Amadeu...</td>\n",
" <td>Mozart</td>\n",
" <td>¿Qué significa “Amadeus”?</td>\n",
" <td>Significa 'amado de Dios' en latín, aunque tam...</td>\n",
" </tr>\n",
" <tr>\n",
" <th>4</th>\n",
" <td>Tema: Mozart, Pregunta: ¿Qué famosa ópera comp...</td>\n",
" <td>Mozart</td>\n",
" <td>¿Qué famosa ópera compuso en 1786?</td>\n",
" <td>Compuso 'Las bodas de Fígaro' (Le nozze di Fig...</td>\n",
" </tr>\n",
" <tr>\n",
" <th>5</th>\n",
" <td>Tema: Mozart, Pregunta: ¿Cuál es su ópera cons...</td>\n",
" <td>Mozart</td>\n",
" <td>¿Cuál es su ópera considerada la más oscura y ...</td>\n",
" <td>Sin duda 'Don Giovanni', que mezcla comedia y ...</td>\n",
" </tr>\n",
" <tr>\n",
" <th>6</th>\n",
" <td>Tema: Mozart, Pregunta: ¿Qué obra dejó inconcl...</td>\n",
" <td>Mozart</td>\n",
" <td>¿Qué obra dejó inconclusa al morir?</td>\n",
" <td>Su famoso Réquiem en Re menor, que estaba comp...</td>\n",
" </tr>\n",
" <tr>\n",
" <th>7</th>\n",
" <td>Tema: Mozart, Pregunta: ¿Quién terminó su Réqu...</td>\n",
" <td>Mozart</td>\n",
" <td>¿Quién terminó su Réquiem?</td>\n",
" <td>Fue su alumno Franz Xaver Süssmayr, quien comp...</td>\n",
" </tr>\n",
" <tr>\n",
" <th>8</th>\n",
" <td>Tema: Mozart, Pregunta: ¿En qué ciudad murió M...</td>\n",
" <td>Mozart</td>\n",
" <td>¿En qué ciudad murió Mozart?</td>\n",
" <td>Murió en Viena, la capital de Austria, donde p...</td>\n",
" </tr>\n",
" <tr>\n",
" <th>9</th>\n",
" <td>Tema: Mozart, Pregunta: ¿A qué edad murió?, Re...</td>\n",
" <td>Mozart</td>\n",
" <td>¿A qué edad murió?</td>\n",
" <td>Murió muy joven, a los 35 años, dejando más de...</td>\n",
" </tr>\n",
" </tbody>\n",
"</table>\n",
"</div>"
],
"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
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Binary file not shown.

View File

@@ -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."
1 Compositor Pregunta Respuesta
2 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.
3 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.
4 Mozart ¿Cuál es su nombre completo? Su nombre completo era Johannes Chrysostomus Wolfgangus Theophilus Mozart, aunque usaba Wolfgang Amadeus Mozart para presentarse profesionalmente.
5 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.
6 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.
7 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.
8 Mozart ¿Qué obra dejó inconclusa al morir? Su famoso Réquiem en Re menor, que estaba componiendo por encargo cuando falleció repentinamente en 1791.
9 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.
10 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.
11 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.
12 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.
13 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.
14 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.
15 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.
16 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.
17 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.
18 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.
19 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.
20 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.
21 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.
22 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.
23 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.
24 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.
25 Beethoven ¿Cómo se llama su única ópera? Fidelio, que trata sobre la libertad, la justicia y el amor conyugal, temas profundos para él.
26 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.
27 Beethoven ¿Cuál es su sonata para piano más famosa? La Sonata No. 14, conocida como 'Claro de Luna', evocadora y profundamente emotiva.
28 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.
29 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.
30 Beethoven ¿Cuál fue su última sinfonía? La Novena Sinfonía, con su revolucionario final coral y mensaje universal de fraternidad.
31 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.
32 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.
33 Beethoven ¿A qué edad murió? Murió a los 56 años tras años de enfermedades, pero dejando un legado inmortal.
34 Beethoven ¿De qué murió Beethoven? Principalmente de cirrosis hepática y complicaciones renales, probablemente exacerbadas por su estilo de vida y medicación.
35 Beethoven ¿Tocaba algún instrumento? Era un pianista virtuoso y también tocaba el órgano y la viola cuando era joven.
36 Beethoven ¿Cuál fue su maestro más influyente? Joseph Haydn, aunque tuvieron diferencias creativas, Haydn reconoció su genialidad.
37 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.
38 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.
39 Beethoven ¿En qué ciudad murió? Murió en Viena, donde pasó la mayor parte de su vida como compositor y pianista.
40 Beethoven ¿Qué pieza para piano expresa su frustración y lucha? La Sonata No. 8 'Patética', llena de contrastes dramáticos y ternura.
41 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.
42 Liszt ¿Dónde nació Liszt? Nació en Raiding, un pueblo húngaro que hoy pertenece a Austria, y desde joven mostró virtuosismo al piano.
43 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.
44 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.
45 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.
46 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.
47 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.
48 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.
49 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.
50 Liszt ¿Con qué estilo musical se le asocia? Con el romanticismo, destacando por su pasión, virtuosismo y creación del poema sinfónico.
51 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.

View File

@@ -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"
}
}
}
}
}
}
}
}
}
}
}

View File

@@ -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\"]"
}
}
}
}
}
}

View File

@@ -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"]'

12
requirements.txt Normal file
View File

@@ -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

109
static/css/styles.css Normal file
View File

@@ -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; }
}

0
static/img/.gitkeep Normal file
View File

BIN
static/img/LogoFIT.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 949 KiB

20
static/js/app.js Normal file
View File

@@ -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);
});
});

0
tests/__init__.py Normal file
View File

38
tests/conftest.py Normal file
View File

@@ -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()

56
tests/test_admin.py Normal file
View File

@@ -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

View File

@@ -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)

122
tests/test_frontend.py Normal file
View File

@@ -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

5
tests/test_health.py Normal file
View File

@@ -0,0 +1,5 @@
def test_health(client):
response = client.get("/health")
assert response.status_code == 200
body = response.json()
assert body["status"] == "ok"

23
tests/test_reports_api.py Normal file
View File

@@ -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 "<table" in content