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:
8
.env.example
Normal file
8
.env.example
Normal 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
14
.gitignore
vendored
Normal 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
21
Dockerfile
Normal 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
72
README.md
Normal 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
848
SPEC_taller_wox_fitlabs.md
Normal 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
0
app/__init__.py
Normal file
42
app/admin.py
Normal file
42
app/admin.py
Normal 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
84
app/benefits_api.py
Normal 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
21
app/config.py
Normal 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()
|
||||
11
app/data/aetna_claim_review_summary.csv
Normal file
11
app/data/aetna_claim_review_summary.csv
Normal 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
|
||||
|
8
app/data/aetna_email.txt
Normal file
8
app/data/aetna_email.txt
Normal 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.
|
||||
29
app/data/available_procedures.csv
Normal file
29
app/data/available_procedures.csv
Normal 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
|
||||
|
20
app/data/combined_email.txt
Normal file
20
app/data/combined_email.txt
Normal 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
|
||||
51
app/data/historical_procedures.csv
Normal file
51
app/data/historical_procedures.csv
Normal 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."
|
||||
|
76
app/data/member_insights.json
Normal file
76
app/data/member_insights.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
5
app/data/provider_email.txt
Normal file
5
app/data/provider_email.txt
Normal 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.
|
||||
0
app/data/reports_output/.gitkeep
Normal file
0
app/data/reports_output/.gitkeep
Normal file
1
app/data/schedule_response.txt
Normal file
1
app/data/schedule_response.txt
Normal 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
128
app/db.py
Normal 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
136
app/frontend.py
Normal 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
47
app/main.py
Normal 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
129
app/reports_api.py
Normal 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
41
app/security.py
Normal 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
36
app/templates/base.html
Normal 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>
|
||||
25
app/templates/descargas.html
Normal file
25
app/templates/descargas.html
Normal 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
63
app/templates/index.html
Normal 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
21
app/templates/report.html
Normal 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>
|
||||
2910
docs/superpowers/plans/2026-05-12-taller-wox.md
Normal file
2910
docs/superpowers/plans/2026-05-12-taller-wox.md
Normal file
File diff suppressed because it is too large
Load Diff
460
docs/superpowers/specs/2026-05-12-taller-wox-design.md
Normal file
460
docs/superpowers/specs/2026-05-12-taller-wox-design.md
Normal 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
BIN
images/LogoFIT.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 949 KiB |
0
material/.gitkeep
Normal file
0
material/.gitkeep
Normal file
91
material/LEEME.txt
Normal file
91
material/LEEME.txt
Normal 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
|
||||
BIN
material/dental_benefits_summary.pdf
Normal file
BIN
material/dental_benefits_summary.pdf
Normal file
Binary file not shown.
876
material/main-desk-concierge-action.json
Normal file
876
material/main-desk-concierge-action.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
624
material/material-extra/RAG-Elastic.ipynb
Normal file
624
material/material-extra/RAG-Elastic.ipynb
Normal 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
|
||||
}
|
||||
BIN
material/material-extra/askbenefits-lab.pdf
Normal file
BIN
material/material-extra/askbenefits-lab.pdf
Normal file
Binary file not shown.
BIN
material/material-extra/deck-ibm-agents.pptx
Normal file
BIN
material/material-extra/deck-ibm-agents.pptx
Normal file
Binary file not shown.
BIN
material/material-extra/diagrama-arquitectura.png
Normal file
BIN
material/material-extra/diagrama-arquitectura.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
BIN
material/material-extra/manual-completo-bootcamp.pdf
Normal file
BIN
material/material-extra/manual-completo-bootcamp.pdf
Normal file
Binary file not shown.
BIN
material/material-extra/paper-react-yao-2022.pdf
Normal file
BIN
material/material-extra/paper-react-yao-2022.pdf
Normal file
Binary file not shown.
51
material/material-extra/qna.csv
Normal file
51
material/material-extra/qna.csv
Normal 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."
|
||||
|
72
material/openapi-tools-report.json
Normal file
72
material/openapi-tools-report.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
146
material/openapi-tools-spec.json
Normal file
146
material/openapi-tools-spec.json
Normal 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\"]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
355
material/openapi-tools-spec.yaml
Normal file
355
material/openapi-tools-spec.yaml
Normal 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
12
requirements.txt
Normal 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
109
static/css/styles.css
Normal 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
0
static/img/.gitkeep
Normal file
BIN
static/img/LogoFIT.png
Normal 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
20
static/js/app.js
Normal 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
0
tests/__init__.py
Normal file
38
tests/conftest.py
Normal file
38
tests/conftest.py
Normal 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
56
tests/test_admin.py
Normal 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
|
||||
85
tests/test_benefits_api.py
Normal file
85
tests/test_benefits_api.py
Normal 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
122
tests/test_frontend.py
Normal 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
5
tests/test_health.py
Normal 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
23
tests/test_reports_api.py
Normal 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
|
||||
Reference in New Issue
Block a user