Files
taller-wox/docs/superpowers/plans/2026-05-12-taller-wox.md
farentsen a062b45c51 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>
2026-05-13 03:04:28 +00:00

100 KiB
Raw Permalink Blame History

Portal taller-wox.fitlabs.dev — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a FastAPI service that serves a branded FactorIT landing page + lead-capture registration + gated download of 2 ZIPs, plus 5 REST endpoints consumed live by IBM watsonx Orchestrate agents during the workshop on 2026-05-14.

Architecture: Single FastAPI service in one Docker container deployed on Coolify. SQLite for leads, file-based CSVs/JSON loaded in memory at boot, Jinja2 + Plotly for the report endpoint, itsdangerous for download tokens. HTTPS via Let's Encrypt (auto from Coolify). Three persistent volumes: leads.db, app/data/reports_output/, material/.

Tech Stack: Python 3.11 · FastAPI · Uvicorn · SQLite · pandas · Jinja2 · Plotly · itsdangerous · pydantic-settings · pytest · Docker

Source of truth for design decisions: docs/superpowers/specs/2026-05-12-taller-wox-design.md. When the original SPEC (SPEC_taller_wox_fitlabs.md) and this design disagree, the design wins.


File Structure

Files this plan creates (locked decomposition):

app/
├── __init__.py             # empty, marks package
├── main.py                 # FastAPI app, CORS, static mount, router includes, startup
├── config.py               # Settings via pydantic-settings, env vars
├── db.py                   # SQLite init + schema + helpers (upsert_lead, log_download, list_leads, stats)
├── security.py             # token sign/verify (itsdangerous), basic auth dep, honeypot check
├── frontend.py             # router: GET /, GET /descargas, POST /register, GET /download/{filename}
├── benefits_api.py         # router: endpoints 1-4 (historical, available, member-insights, schedule)
├── reports_api.py          # router: endpoint 5 (generate-report) + the StaticFiles mount for /api/reports/output
├── admin.py                # router: /admin/leads.json, /admin/leads.csv, /admin/stats
├── data/
│   ├── historical_procedures.csv      # ~50 rows, synthetic
│   ├── available_procedures.csv       # ~28 rows, synthetic
│   ├── member_insights.json           # static, dates re-calibrated to 2026
│   ├── schedule_response.txt          # static text
│   ├── combined_email.txt             # for endpoint 5
│   ├── provider_email.txt             # for endpoint 5
│   ├── aetna_email.txt                # for endpoint 5
│   ├── aetna_claim_review_summary.csv # for endpoint 5, ~10 rows
│   └── reports_output/                # generated HTMLs land here (persistent volume in prod)
│       └── .gitkeep
└── templates/
    ├── base.html           # shared layout: header, footer, fonts, palette
    ├── index.html          # landing + inline registration form
    ├── descargas.html      # post-registration with 2 download cards
    └── report.html         # wrapper for endpoint 5 generated reports

static/
├── css/styles.css          # FIT palette, hero, cards, form, responsive
├── img/
│   ├── LogoFIT.png         # existing, compressed copy goes here
│   └── favicon.ico         # placeholder generated from LogoFIT
└── js/app.js               # honeypot bot detection (light), smooth scroll, form UX

tests/
├── __init__.py
├── conftest.py             # pytest fixtures: TestClient, temp SQLite, env vars
├── test_db.py
├── test_security.py
├── test_frontend.py
├── test_benefits_api.py
├── test_reports_api.py
└── test_admin.py

material/                   # empty in repo (gitignored); Felipe uploads zips to volume in prod
└── .gitkeep

Dockerfile
requirements.txt
.env.example
README.md

Responsibility per file:

  • config.py owns env-var loading. No other module reads os.environ directly.
  • db.py owns the SQLite connection lifecycle and schema. No SQL strings outside this file.
  • security.py owns token serialization and the basic-auth dependency. No router does its own crypto.
  • Each router file (frontend.py, benefits_api.py, reports_api.py, admin.py) only knows its own routes and depends on db, security, config as needed.
  • main.py is the only place that wires routers, middleware, and static mounts. Touching main.py should be rare after Task 2.

Task ordering rationale

Tasks 1-4 are foundation (project skeleton, config, DB, security). Tasks 5-9 are the workshop API (the critical path for Thursday — must work end-to-end first). Tasks 10-13 are the frontend (visible but lower risk; you can ship the API without it for last-minute use). Task 14 is admin (lowest priority). Task 15 is Dockerization + deploy notes.

If time runs out, the order above is also the priority order to abandon from the bottom up.


Task 1: Project bootstrap

Files:

  • Create: requirements.txt

  • Create: .env.example

  • Create: app/__init__.py (empty)

  • Create: app/main.py

  • Create: tests/__init__.py (empty)

  • Create: tests/conftest.py

  • Create: tests/test_smoke.py

  • Step 1: Write requirements.txt

fastapi==0.115.0
uvicorn[standard]==0.32.0
pydantic==2.9.2
pydantic-settings==2.6.1
python-multipart==0.0.12
itsdangerous==2.2.0
jinja2==3.1.4
pandas==2.2.3
plotly==5.24.1
pytest==8.3.3
httpx==0.27.2

httpx is needed for FastAPI's TestClient. python-multipart for form parsing. Pinned versions for reproducibility.

  • Step 2: Write .env.example
# Secret for signing download tokens. Use `python -c "import secrets; print(secrets.token_urlsafe(48))"` to generate
SECRET_KEY=replace-me-with-a-long-random-string

# HTTP Basic auth for /admin/*
ADMIN_USER=admin
ADMIN_PASS=replace-me

# Download token expiration in hours
TOKEN_EXPIRY_HOURS=24

# Public base URL (used in report URLs and elsewhere)
BASE_URL=https://taller-wox.fitlabs.dev

# Path where Felipe uploads the 2 zips (mounted volume in Coolify, ./material in dev)
MATERIAL_DIR=./material

# Path where SQLite file lives (mounted file in Coolify, ./leads.db in dev)
DB_PATH=./leads.db

# Where generated reports are written (mounted dir in Coolify)
REPORTS_OUTPUT_DIR=./app/data/reports_output
  • Step 3: Write app/main.py (minimal)
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI(title="taller-wox.fitlabs.dev", version="1.0.0")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=False,
    allow_methods=["GET", "POST"],
    allow_headers=["*"],
)


@app.get("/health")
def health():
    return {"status": "ok"}
  • Step 4: Write tests/conftest.py
import os
import tempfile
from pathlib import Path

import pytest

# Set test env BEFORE importing the app
os.environ["SECRET_KEY"] = "test-secret-key-only-for-tests-not-secure"
os.environ["ADMIN_USER"] = "testadmin"
os.environ["ADMIN_PASS"] = "testpass"
os.environ["TOKEN_EXPIRY_HOURS"] = "24"
os.environ["BASE_URL"] = "http://testserver"

_tmp = Path(tempfile.mkdtemp(prefix="taller-wox-test-"))
os.environ["DB_PATH"] = str(_tmp / "test_leads.db")
os.environ["MATERIAL_DIR"] = str(_tmp / "material")
os.environ["REPORTS_OUTPUT_DIR"] = str(_tmp / "reports_output")
(_tmp / "material").mkdir(parents=True, exist_ok=True)
(_tmp / "reports_output").mkdir(parents=True, exist_ok=True)


@pytest.fixture
def client():
    from fastapi.testclient import TestClient
    from app.main import app
    return TestClient(app)
  • Step 5: Write tests/test_smoke.py
def test_health(client):
    response = client.get("/health")
    assert response.status_code == 200
    assert response.json() == {"status": "ok"}
  • Step 6: Install deps and run the test
python3.11 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
pytest tests/test_smoke.py -v

Expected: 1 passed.

  • Step 7: Verify dev server starts
uvicorn app.main:app --reload --port 8000

In another terminal:

curl http://localhost:8000/health

Expected output: {"status":"ok"}. Stop the server with Ctrl+C.

  • Step 8: Commit
git add requirements.txt .env.example app/ tests/
git commit -m "feat(bootstrap): minimal FastAPI app with health endpoint and test harness"

Task 2: Settings via pydantic-settings

Files:

  • Create: app/config.py

  • Modify: app/main.py

  • Create: tests/test_config.py

  • Step 1: Write the failing test

# tests/test_config.py
from app.config import get_settings


def test_settings_loaded_from_env():
    settings = get_settings()
    assert settings.secret_key == "test-secret-key-only-for-tests-not-secure"
    assert settings.admin_user == "testadmin"
    assert settings.admin_pass == "testpass"
    assert settings.token_expiry_hours == 24
    assert settings.base_url == "http://testserver"
    assert settings.db_path.endswith("test_leads.db")
    assert settings.material_dir.endswith("material")
    assert settings.reports_output_dir.endswith("reports_output")
  • Step 2: Run test to verify it fails
pytest tests/test_config.py -v

Expected: ModuleNotFoundError: No module named 'app.config'.

  • Step 3: Implement app/config.py
from functools import lru_cache

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    secret_key: str
    admin_user: str
    admin_pass: str
    token_expiry_hours: int = 24
    base_url: str = "https://taller-wox.fitlabs.dev"
    db_path: str = "./leads.db"
    material_dir: str = "./material"
    reports_output_dir: str = "./app/data/reports_output"

    model_config = SettingsConfigDict(env_file=".env", case_sensitive=False)


@lru_cache
def get_settings() -> Settings:
    return Settings()
  • Step 4: Run test to verify it passes
pytest tests/test_config.py -v

Expected: 1 passed.

  • Step 5: Wire settings into app/main.py

Replace app/main.py with:

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from app.config import get_settings

settings = get_settings()

app = FastAPI(title="taller-wox.fitlabs.dev", version="1.0.0")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=False,
    allow_methods=["GET", "POST"],
    allow_headers=["*"],
)


@app.get("/health")
def health():
    return {"status": "ok", "base_url": settings.base_url}
  • Step 6: Re-run smoke test
pytest -v

Expected: 2 passed.

  • Step 7: Commit
git add app/config.py app/main.py tests/test_config.py
git commit -m "feat(config): typed settings via pydantic-settings"

Task 3: SQLite layer (db.py)

Files:

  • Create: app/db.py

  • Create: tests/test_db.py

  • Step 1: Write failing tests

# tests/test_db.py
from datetime import datetime

import pytest

from app.db import (
    init_db,
    upsert_lead,
    get_lead_by_email,
    log_download,
    list_leads,
    stats,
)


@pytest.fixture(autouse=True)
def fresh_db(tmp_path, monkeypatch):
    """Each test gets its own DB file so they don't bleed into each other."""
    db_file = tmp_path / "test.db"
    monkeypatch.setenv("DB_PATH", str(db_file))
    from app import config
    config.get_settings.cache_clear()
    init_db()
    yield
    config.get_settings.cache_clear()


def test_upsert_new_lead_creates_row():
    lead_id = upsert_lead(
        nombre="Felipe",
        email="felipe@factorit.com",
        empresa="FactorIT",
        ip="1.2.3.4",
        user_agent="pytest",
        consent=True,
    )
    assert lead_id > 0
    lead = get_lead_by_email("felipe@factorit.com")
    assert lead["nombre"] == "Felipe"
    assert lead["empresa"] == "FactorIT"
    assert lead["times_registered"] == 1


def test_upsert_duplicate_email_increments_times_registered():
    upsert_lead(
        nombre="Felipe",
        email="felipe@factorit.com",
        empresa="FactorIT",
        ip="1.2.3.4",
        user_agent="pytest",
        consent=True,
    )
    upsert_lead(
        nombre="Felipe Arentsen",
        email="felipe@factorit.com",
        empresa="FactorIT Chile",
        ip="5.6.7.8",
        user_agent="pytest-2",
        consent=True,
    )
    lead = get_lead_by_email("felipe@factorit.com")
    assert lead["times_registered"] == 2
    # latest data wins
    assert lead["nombre"] == "Felipe Arentsen"
    assert lead["empresa"] == "FactorIT Chile"


def test_log_download_records_event():
    log_download(
        lead_email="felipe@factorit.com",
        filename="taller-wox-tecnico.zip",
        ip="1.2.3.4",
    )
    s = stats()
    assert s["total_downloads"] == 1
    assert s["downloads_por_archivo"]["taller-wox-tecnico.zip"] == 1


def test_list_leads_paginates():
    for i in range(5):
        upsert_lead(
            nombre=f"User {i}",
            email=f"user{i}@test.com",
            empresa=f"Co {i}",
            ip="1.2.3.4",
            user_agent="pytest",
            consent=True,
        )
    page = list_leads(limit=2, offset=0)
    assert len(page) == 2
    page2 = list_leads(limit=2, offset=2)
    assert len(page2) == 2
    assert page[0]["email"] != page2[0]["email"]


def test_stats_top_empresas():
    for empresa in ["ACME", "ACME", "ACME", "Globex", "Globex", "Initech"]:
        upsert_lead(
            nombre="X",
            email=f"{empresa}-{datetime.utcnow().timestamp()}@x.com",
            empresa=empresa,
            ip="1.2.3.4",
            user_agent="pytest",
            consent=True,
        )
    s = stats()
    assert s["total_leads"] == 6
    assert s["top_5_empresas"][0] == {"empresa": "ACME", "count": 3}
  • Step 2: Run tests to verify they fail
pytest tests/test_db.py -v

Expected: ModuleNotFoundError or ImportError on app.db.

  • Step 3: Implement app/db.py
import sqlite3
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator

from app.config import get_settings

SCHEMA = """
CREATE TABLE IF NOT EXISTS leads (
    id                INTEGER PRIMARY KEY AUTOINCREMENT,
    nombre            TEXT NOT NULL,
    email             TEXT NOT NULL UNIQUE,
    empresa           TEXT NOT NULL,
    ip                TEXT,
    user_agent        TEXT,
    consent           INTEGER NOT NULL DEFAULT 0,
    times_registered  INTEGER NOT NULL DEFAULT 1,
    created_at        TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    last_seen         TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS downloads (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    lead_email    TEXT NOT NULL,
    filename      TEXT NOT NULL,
    ip            TEXT,
    downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_downloads_email ON downloads(lead_email);
CREATE INDEX IF NOT EXISTS idx_downloads_filename ON downloads(filename);
"""


@contextmanager
def _conn() -> Iterator[sqlite3.Connection]:
    settings = get_settings()
    Path(settings.db_path).parent.mkdir(parents=True, exist_ok=True)
    conn = sqlite3.connect(settings.db_path)
    conn.row_factory = sqlite3.Row
    try:
        yield conn
        conn.commit()
    finally:
        conn.close()


def init_db() -> None:
    with _conn() as conn:
        conn.executescript(SCHEMA)


def upsert_lead(
    nombre: str,
    email: str,
    empresa: str,
    ip: str | None,
    user_agent: str | None,
    consent: bool,
) -> int:
    """Insert a new lead, or update the existing row if email already exists.

    Returns the lead's id. Increments times_registered on duplicates.
    """
    with _conn() as conn:
        cur = conn.execute(
            """
            INSERT INTO leads (nombre, email, empresa, ip, user_agent, consent)
            VALUES (?, ?, ?, ?, ?, ?)
            ON CONFLICT(email) DO UPDATE SET
                nombre = excluded.nombre,
                empresa = excluded.empresa,
                ip = excluded.ip,
                user_agent = excluded.user_agent,
                consent = excluded.consent,
                times_registered = times_registered + 1,
                last_seen = CURRENT_TIMESTAMP
            RETURNING id
            """,
            (nombre, email, empresa, ip, user_agent, 1 if consent else 0),
        )
        return cur.fetchone()["id"]


def get_lead_by_email(email: str) -> dict | None:
    with _conn() as conn:
        row = conn.execute(
            "SELECT * FROM leads WHERE email = ?", (email,)
        ).fetchone()
        return dict(row) if row else None


def log_download(lead_email: str, filename: str, ip: str | None) -> None:
    with _conn() as conn:
        conn.execute(
            "INSERT INTO downloads (lead_email, filename, ip) VALUES (?, ?, ?)",
            (lead_email, filename, ip),
        )


def list_leads(limit: int = 100, offset: int = 0) -> list[dict]:
    with _conn() as conn:
        rows = conn.execute(
            "SELECT * FROM leads ORDER BY id ASC LIMIT ? OFFSET ?",
            (limit, offset),
        ).fetchall()
        return [dict(r) for r in rows]


def stats() -> dict:
    with _conn() as conn:
        total_leads = conn.execute("SELECT COUNT(*) AS c FROM leads").fetchone()["c"]
        total_downloads = conn.execute("SELECT COUNT(*) AS c FROM downloads").fetchone()["c"]
        per_file = {
            r["filename"]: r["c"]
            for r in conn.execute(
                "SELECT filename, COUNT(*) AS c FROM downloads GROUP BY filename"
            ).fetchall()
        }
        top_empresas = [
            {"empresa": r["empresa"], "count": r["c"]}
            for r in conn.execute(
                "SELECT empresa, COUNT(*) AS c FROM leads GROUP BY empresa ORDER BY c DESC LIMIT 5"
            ).fetchall()
        ]
        return {
            "total_leads": total_leads,
            "total_downloads": total_downloads,
            "downloads_por_archivo": per_file,
            "top_5_empresas": top_empresas,
        }
  • Step 4: Run tests to verify they pass
pytest tests/test_db.py -v

Expected: 5 passed.

  • Step 5: Commit
git add app/db.py tests/test_db.py
git commit -m "feat(db): sqlite schema + lead/download helpers with upsert-on-email"

Task 4: Security (tokens, basic auth, honeypot)

Files:

  • Create: app/security.py

  • Create: tests/test_security.py

  • Step 1: Write failing tests

# tests/test_security.py
import time

import pytest
from itsdangerous import BadSignature, SignatureExpired

from app.security import (
    create_download_token,
    verify_download_token,
    is_honeypot_filled,
)


def test_token_roundtrip():
    token = create_download_token(email="x@y.com", nombre="X")
    data = verify_download_token(token)
    assert data["email"] == "x@y.com"
    assert data["nombre"] == "X"


def test_token_tampering_raises():
    token = create_download_token(email="x@y.com", nombre="X")
    tampered = token[:-3] + "AAA"
    with pytest.raises(BadSignature):
        verify_download_token(tampered)


def test_token_expiry(monkeypatch):
    monkeypatch.setenv("TOKEN_EXPIRY_HOURS", "0")  # expire immediately
    from app import config
    config.get_settings.cache_clear()
    token = create_download_token(email="x@y.com", nombre="X")
    time.sleep(1.1)
    with pytest.raises(SignatureExpired):
        verify_download_token(token)
    config.get_settings.cache_clear()


def test_honeypot_empty_returns_false():
    assert is_honeypot_filled("") is False
    assert is_honeypot_filled(None) is False


def test_honeypot_filled_returns_true():
    assert is_honeypot_filled("https://spam.com") is True
    assert is_honeypot_filled("anything") is True
  • Step 2: Run tests to verify they fail
pytest tests/test_security.py -v

Expected: ImportError on app.security.

  • Step 3: Implement app/security.py
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from itsdangerous import URLSafeTimedSerializer
import secrets

from app.config import get_settings


_basic = HTTPBasic()


def _serializer() -> URLSafeTimedSerializer:
    return URLSafeTimedSerializer(get_settings().secret_key, salt="download")


def create_download_token(email: str, nombre: str) -> str:
    return _serializer().dumps({"email": email, "nombre": nombre})


def verify_download_token(token: str) -> dict:
    """Returns {'email': ..., 'nombre': ...}. Raises SignatureExpired or BadSignature."""
    settings = get_settings()
    max_age_seconds = max(1, settings.token_expiry_hours * 3600)
    return _serializer().loads(token, max_age=max_age_seconds)


def is_honeypot_filled(value: str | None) -> bool:
    return bool(value)


def require_admin(credentials: HTTPBasicCredentials = Depends(_basic)) -> str:
    """FastAPI dependency: enforces HTTP Basic auth against env-configured admin creds."""
    settings = get_settings()
    user_ok = secrets.compare_digest(credentials.username, settings.admin_user)
    pass_ok = secrets.compare_digest(credentials.password, settings.admin_pass)
    if not (user_ok and pass_ok):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid credentials",
            headers={"WWW-Authenticate": "Basic"},
        )
    return credentials.username
  • Step 4: Run tests to verify they pass
pytest tests/test_security.py -v

Expected: 5 passed.

  • Step 5: Commit
git add app/security.py tests/test_security.py
git commit -m "feat(security): signed download tokens, basic-auth dep, honeypot check"

Task 5: Static datasets for the workshop API

This task creates the data files the 5 endpoints read at boot. Coherence between files matters (see design doc §6).

Files:

  • Create: app/data/member_insights.json

  • Create: app/data/schedule_response.txt

  • Create: app/data/combined_email.txt

  • Create: app/data/provider_email.txt

  • Create: app/data/aetna_email.txt

  • Create: app/data/aetna_claim_review_summary.csv

  • Create: app/data/historical_procedures.csv

  • Create: app/data/available_procedures.csv

  • Create: app/data/reports_output/.gitkeep (empty)

  • Step 1: Write app/data/member_insights.json

Dates re-calibrated for today (2026-05-12):

{
  "result": {
    "member": {
      "name": "Charlie Smith",
      "date_of_birth": "2013-03-04",
      "plan": "Gold PPO",
      "member_id": "CS-001-2024"
    },
    "medical_plan": {
      "name": "Gold PPO",
      "deductible": 1500,
      "deductible_met": 850,
      "out_of_pocket_max": 6000,
      "out_of_pocket_met": 1200,
      "coinsurance": "20%",
      "primary_care_copay": 25,
      "specialist_copay": 50,
      "emergency_room_copay": 250
    },
    "pharmacy_plan": {
      "tier_1_copay": 10,
      "tier_2_copay": 30,
      "tier_3_copay": 60,
      "tier_4_coinsurance": "30%",
      "mail_order_available": true
    },
    "mental_health": {
      "covered_visits_per_year": 20,
      "visits_used": 4,
      "telehealth": true,
      "in_network_providers": "https://taller-wox.fitlabs.dev/docs/mental-health-providers"
    },
    "wellness": {
      "gym_reimbursement_annual": 300,
      "gym_reimbursement_used": 150,
      "annual_checkup_covered": true,
      "preventive_care_100_percent": true,
      "flu_shot_covered": true
    },
    "tax_documents": {
      "form_1095_available": true,
      "form_1095_url": "https://taller-wox.fitlabs.dev/docs/1095-2025.pdf",
      "instructions": "Tu formulario 1095 está disponible en el portal del afiliado bajo Documentos > Impuestos. Si no lo puedes acceder, llama al 1-800-FIT-CARE."
    },
    "overdue_procedures": [
      {
        "procedure": "Annual Physical Exam",
        "last_date": "2024-05-15",
        "recommended_frequency_months": 12,
        "due_since_months": 12,
        "priority": "high"
      },
      {
        "procedure": "Dental Cleaning",
        "last_date": "2025-01-10",
        "recommended_frequency_months": 6,
        "due_since_months": 10,
        "priority": "medium"
      },
      {
        "procedure": "Vision Exam",
        "last_date": "2023-08-22",
        "recommended_frequency_months": 24,
        "due_since_months": 8,
        "priority": "medium"
      },
      {
        "procedure": "Blood Test - Cholesterol Panel",
        "last_date": "2024-11-04",
        "recommended_frequency_months": 12,
        "due_since_months": 6,
        "priority": "low"
      }
    ]
  }
}
  • Step 2: Write app/data/schedule_response.txt

Single line (no JSON wrapper — endpoint adds that):

Para agendar una cita médica, sigue estos pasos: 1) Confirma con el afiliado el día y hora preferidos, y el tipo de procedimiento. 2) Verifica que el procedimiento esté cubierto por su plan (Gold PPO en este caso). 3) Llama al sistema de agendamiento de FIT Care al 1-800-FIT-CARE o entra al portal en https://taller-wox.fitlabs.dev/agenda. 4) Indica el procedimiento, el proveedor preferido (City Hospital, Green Valley Clinic, Sunrise Health o Regional Medical Center) y la fecha. 5) Confirma la cita y registra el número de confirmación. 6) Envía recordatorios automáticos al afiliado 24h y 1h antes. Recuerda: si el procedimiento requiere autorización previa, gestiónala antes de confirmar la cita.
  • Step 3: Write app/data/combined_email.txt
From: dr.martinez@cityhospital.com
Subject: Follow-up after appointment

Hi Charlie,

Following our visit last week, I'm recommending a follow-up CT scan
to confirm the diagnosis. Please schedule within the next 2 weeks.

Best,
Dr. Martinez

---

From: charlie.smith@gmail.com
Subject: Re: Follow-up

Thanks Dr. Martinez. Will the CT scan be covered by my Gold PPO plan?
Also, can I get a copy of the lab results from last visit?

Charlie
  • Step 4: Write app/data/provider_email.txt
Patient presented with bilateral lower quadrant tenderness, WBC 14k,
CRP elevated. Differential includes appendicitis vs diverticulitis.
Recommending CT abdomen/pelvis with contrast STAT.
CPT 74177 ordered. Pre-auth obtained: AUTH-2024-8821.
Patient stable, NPO since midnight. Will reassess in 2h.
  • Step 5: Write app/data/aetna_email.txt
Claim #AET-2024-9912 has been processed.
Service date: 2024-04-15
Provider: City Hospital
Total billed: $1,847.50
Plan allowance: $1,200.00
Plan paid: $960.00
Patient responsibility (20% coinsurance after deductible): $240.00
EOB available at member portal.
  • Step 6: Write app/data/aetna_claim_review_summary.csv
Date,CPT_Code,Description,Charged_Amount,Allowed_Amount,Plan_Paid,Patient_Responsibility
2024-04-15,74177,CT Abdomen/Pelvis w/contrast,1847.50,1200.00,960.00,240.00
2024-03-22,99213,Office visit established patient,185.00,120.00,96.00,24.00
2024-02-10,80061,Lipid panel,82.00,55.00,44.00,11.00
2024-01-18,99395,Preventive visit adult,310.00,250.00,250.00,0.00
2023-12-05,90686,Influenza vaccine,55.00,40.00,40.00,0.00
2023-11-12,87880,Strep A direct test,42.00,28.00,22.40,5.60
2023-10-08,71046,Chest X-ray 2 views,210.00,140.00,112.00,28.00
2023-09-22,93000,Electrocardiogram complete,138.00,90.00,72.00,18.00
2023-08-14,99214,Office visit established mod complexity,250.00,170.00,136.00,34.00
2023-07-03,36415,Venipuncture,18.00,12.00,9.60,2.40
  • Step 7: Write app/data/historical_procedures.csv

50 rows. Charlie Smith's rows MUST line up with member_insights.json's overdue_procedures (matching last_date).

Columns (exact order): member_name,relationship,age,gender,procedure,procedure_type,location,date,in_network,member_plan,accepted_plans,cost_facility,cost_physician,cost_anesthesia,cost_medication,total_cost,facility_rating,notes

Write this exact CSV (Charlie Smith rows first 4 — coherent with member_insights.json; rest are filler):

member_name,relationship,age,gender,procedure,procedure_type,location,date,in_network,member_plan,accepted_plans,cost_facility,cost_physician,cost_anesthesia,cost_medication,total_cost,facility_rating,notes
Charlie Smith,Self,13,Male,Annual Physical Exam,preventive,City Hospital,2024-05-15,true,Gold PPO,"Gold PPO, Medicare Advantage",80.00,180.00,0.00,5.00,265.00,4.7,"Annual Physical Exam performed at City Hospital."
Charlie Smith,Self,13,Male,Dental Cleaning,preventive,Green Valley Clinic,2025-01-10,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",60.00,120.00,0.00,0.00,180.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
Charlie Smith,Self,13,Male,Vision Exam,preventive,Sunrise Health,2023-08-22,true,Gold PPO,"Gold PPO, Bronze HDHP",40.00,110.00,0.00,0.00,150.00,4.3,"Vision Exam performed at Sunrise Health."
Charlie Smith,Self,13,Male,Blood Test,diagnostic,City Hospital,2024-11-04,true,Gold PPO,"Gold PPO, Medicare Advantage",25.00,55.00,0.00,2.50,82.50,4.7,"Cholesterol Panel performed at City Hospital."
Alice Smith,Spouse,42,Female,Annual Physical Exam,preventive,Regional Medical Center,2024-09-18,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",95.00,210.00,0.00,4.50,309.50,4.6,"Annual Physical Exam performed at Regional Medical Center."
Alice Smith,Spouse,42,Female,MRI,diagnostic,City Hospital,2023-11-02,true,Gold PPO,"Gold PPO, Medicare Advantage",1450.00,820.00,0.00,12.00,2282.00,4.7,"Lumbar MRI performed at City Hospital."
Alice Smith,Spouse,42,Female,Dental Cleaning,preventive,Green Valley Clinic,2024-08-12,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",65.00,125.00,0.00,0.00,190.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
Bob Johnson,Self,55,Male,Appendectomy,surgery,Regional Medical Center,2024-02-04,true,Family Plan - Silver EPO,"Family Plan - Silver EPO, Gold PPO",8200.00,5400.00,2100.00,180.00,15880.00,4.6,"Laparoscopic appendectomy performed at Regional Medical Center."
Bob Johnson,Self,55,Male,Annual Physical Exam,preventive,Regional Medical Center,2024-10-22,false,Family Plan - Silver EPO,"Gold PPO",120.00,260.00,0.00,6.00,386.00,4.6,"Annual Physical Exam performed at Regional Medical Center."
Bob Johnson,Self,55,Male,CT Scan,diagnostic,City Hospital,2024-02-03,true,Family Plan - Silver EPO,"Gold PPO, Medicare Advantage",1600.00,750.00,0.00,8.00,2358.00,4.7,"Pre-op CT abdomen/pelvis at City Hospital."
Diana Roberts,Mother,67,Female,Annual Physical Exam,preventive,Sunrise Health,2024-07-09,true,Gold PPO,"Gold PPO, Bronze HDHP",95.00,220.00,0.00,5.00,320.00,4.3,"Annual Physical Exam performed at Sunrise Health."
Diana Roberts,Mother,67,Female,Vision Exam,preventive,Sunrise Health,2024-10-14,true,Gold PPO,"Gold PPO, Bronze HDHP",40.00,115.00,0.00,0.00,155.00,4.3,"Vision Exam performed at Sunrise Health."
Diana Roberts,Mother,67,Female,Blood Test,diagnostic,City Hospital,2025-02-18,true,Gold PPO,"Gold PPO, Medicare Advantage",28.00,60.00,0.00,3.00,91.00,4.7,"Cholesterol Panel performed at City Hospital."
Diana Roberts,Mother,67,Female,MRI,diagnostic,Regional Medical Center,2023-06-30,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",1520.00,840.00,0.00,10.00,2370.00,4.6,"Knee MRI performed at Regional Medical Center."
Ethan Smith,Son,11,Male,Annual Physical Exam,preventive,City Hospital,2025-04-02,true,Gold PPO,"Gold PPO, Medicare Advantage",70.00,170.00,0.00,4.00,244.00,4.7,"Annual Physical Exam performed at City Hospital."
Ethan Smith,Son,11,Male,Dental Cleaning,preventive,Green Valley Clinic,2025-03-15,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",55.00,115.00,0.00,0.00,170.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
Ethan Smith,Son,11,Male,Vision Exam,preventive,Sunrise Health,2024-12-08,true,Gold PPO,"Gold PPO, Bronze HDHP",35.00,105.00,0.00,0.00,140.00,4.3,"Vision Exam performed at Sunrise Health."
Ethan Smith,Son,11,Male,X Ray,diagnostic,City Hospital,2024-09-21,true,Gold PPO,"Gold PPO, Medicare Advantage",105.00,135.00,0.00,2.00,242.00,4.7,"Wrist X Ray performed at City Hospital."
Alice Smith,Spouse,42,Female,Blood Test,diagnostic,City Hospital,2024-04-19,true,Gold PPO,"Gold PPO, Medicare Advantage",30.00,58.00,0.00,3.50,91.50,4.7,"Lipid panel at City Hospital."
Alice Smith,Spouse,42,Female,Vision Exam,preventive,Sunrise Health,2024-06-11,true,Gold PPO,"Gold PPO, Bronze HDHP",38.00,108.00,0.00,0.00,146.00,4.3,"Vision Exam performed at Sunrise Health."
Bob Johnson,Self,55,Male,Dental Cleaning,preventive,Green Valley Clinic,2024-12-03,true,Family Plan - Silver EPO,"Family Plan - Silver EPO, Gold PPO",60.00,120.00,0.00,0.00,180.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
Bob Johnson,Self,55,Male,Blood Test,diagnostic,Regional Medical Center,2025-01-22,true,Family Plan - Silver EPO,"Family Plan - Silver EPO, Gold PPO",28.00,55.00,0.00,3.00,86.00,4.6,"A1C panel performed at Regional Medical Center."
Charlie Smith,Self,13,Male,X Ray,diagnostic,City Hospital,2025-03-28,true,Gold PPO,"Gold PPO, Medicare Advantage",110.00,140.00,0.00,2.00,252.00,4.7,"Ankle X Ray performed at City Hospital."
Frank Lee,Father,72,Male,CT Scan,diagnostic,Regional Medical Center,2024-08-29,false,Bronze HDHP,"Gold PPO, Medicare Advantage",1750.00,820.00,0.00,9.00,2579.00,4.6,"Head CT performed at Regional Medical Center."
Frank Lee,Father,72,Male,Annual Physical Exam,preventive,Sunrise Health,2024-11-19,true,Bronze HDHP,"Gold PPO, Bronze HDHP",95.00,225.00,0.00,5.50,325.50,4.3,"Annual Physical Exam performed at Sunrise Health."
Frank Lee,Father,72,Male,Vision Exam,preventive,Sunrise Health,2024-09-02,true,Bronze HDHP,"Gold PPO, Bronze HDHP",42.00,118.00,0.00,0.00,160.00,4.3,"Vision Exam performed at Sunrise Health."
Frank Lee,Father,72,Male,Blood Test,diagnostic,City Hospital,2025-02-25,true,Bronze HDHP,"Gold PPO, Medicare Advantage",30.00,58.00,0.00,4.00,92.00,4.7,"Comprehensive metabolic panel at City Hospital."
Grace Wong,Daughter,16,Female,Annual Physical Exam,preventive,Green Valley Clinic,2025-02-11,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",75.00,175.00,0.00,4.00,254.00,4.5,"Annual Physical Exam performed at Green Valley Clinic."
Grace Wong,Daughter,16,Female,Dental Cleaning,preventive,Green Valley Clinic,2024-11-30,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",58.00,118.00,0.00,0.00,176.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
Grace Wong,Daughter,16,Female,Vision Exam,preventive,Sunrise Health,2025-01-26,true,Gold PPO,"Gold PPO, Bronze HDHP",36.00,106.00,0.00,0.00,142.00,4.3,"Vision Exam performed at Sunrise Health."
Henry Park,Self,38,Male,MRI,diagnostic,City Hospital,2024-07-15,true,Family Plan - Silver EPO,"Gold PPO, Family Plan - Silver EPO",1500.00,830.00,0.00,11.00,2341.00,4.7,"Shoulder MRI performed at City Hospital."
Henry Park,Self,38,Male,Annual Physical Exam,preventive,Regional Medical Center,2024-08-08,true,Family Plan - Silver EPO,"Gold PPO, Family Plan - Silver EPO",90.00,210.00,0.00,5.00,305.00,4.6,"Annual Physical Exam performed at Regional Medical Center."
Henry Park,Self,38,Male,X Ray,diagnostic,City Hospital,2024-07-10,true,Family Plan - Silver EPO,"Gold PPO, Medicare Advantage",120.00,145.00,0.00,2.50,267.50,4.7,"Shoulder X Ray performed at City Hospital."
Henry Park,Self,38,Male,Dental Cleaning,preventive,Green Valley Clinic,2024-10-04,true,Family Plan - Silver EPO,"Gold PPO, Family Plan - Silver EPO",62.00,122.00,0.00,0.00,184.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
Isabel Cruz,Mother,69,Female,Appendectomy,surgery,City Hospital,2024-03-12,true,Gold PPO,"Gold PPO, Medicare Advantage",9100.00,5800.00,2200.00,195.00,17295.00,4.7,"Open appendectomy performed at City Hospital."
Isabel Cruz,Mother,69,Female,CT Scan,diagnostic,City Hospital,2024-03-11,true,Gold PPO,"Gold PPO, Medicare Advantage",1620.00,780.00,0.00,8.50,2408.50,4.7,"Pre-op CT abdomen/pelvis at City Hospital."
Isabel Cruz,Mother,69,Female,Annual Physical Exam,preventive,Sunrise Health,2024-06-22,true,Gold PPO,"Gold PPO, Bronze HDHP",95.00,225.00,0.00,5.50,325.50,4.3,"Annual Physical Exam performed at Sunrise Health."
Isabel Cruz,Mother,69,Female,Blood Test,diagnostic,City Hospital,2025-01-30,true,Gold PPO,"Gold PPO, Medicare Advantage",32.00,62.00,0.00,3.50,97.50,4.7,"Comprehensive metabolic panel at City Hospital."
Jack Miller,Self,29,Male,X Ray,diagnostic,Sunrise Health,2024-05-04,true,Bronze HDHP,"Gold PPO, Bronze HDHP",95.00,130.00,0.00,2.00,227.00,4.3,"Knee X Ray performed at Sunrise Health."
Jack Miller,Self,29,Male,Annual Physical Exam,preventive,Sunrise Health,2025-02-06,true,Bronze HDHP,"Gold PPO, Bronze HDHP",85.00,200.00,0.00,4.50,289.50,4.3,"Annual Physical Exam performed at Sunrise Health."
Jack Miller,Self,29,Male,Dental Cleaning,preventive,Green Valley Clinic,2024-10-17,true,Bronze HDHP,"Family Plan - Silver EPO",58.00,118.00,0.00,0.00,176.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
Karen Davis,Spouse,46,Female,MRI,diagnostic,Regional Medical Center,2024-04-09,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",1480.00,810.00,0.00,11.00,2301.00,4.6,"Brain MRI performed at Regional Medical Center."
Karen Davis,Spouse,46,Female,Annual Physical Exam,preventive,Regional Medical Center,2024-11-25,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",100.00,225.00,0.00,5.50,330.50,4.6,"Annual Physical Exam performed at Regional Medical Center."
Karen Davis,Spouse,46,Female,Dental Cleaning,preventive,Green Valley Clinic,2024-09-30,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",60.00,120.00,0.00,0.00,180.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
Liam O'Brien,Son,9,Male,Annual Physical Exam,preventive,City Hospital,2025-03-05,true,Gold PPO,"Gold PPO, Medicare Advantage",70.00,170.00,0.00,4.00,244.00,4.7,"Annual Physical Exam performed at City Hospital."
Liam O'Brien,Son,9,Male,Vision Exam,preventive,Sunrise Health,2024-12-19,true,Gold PPO,"Gold PPO, Bronze HDHP",35.00,105.00,0.00,0.00,140.00,4.3,"Vision Exam performed at Sunrise Health."
Liam O'Brien,Son,9,Male,Dental Cleaning,preventive,Green Valley Clinic,2025-04-11,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",55.00,115.00,0.00,0.00,170.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
Maria Lopez,Daughter,19,Female,Annual Physical Exam,preventive,Green Valley Clinic,2025-01-08,true,Family Plan - Silver EPO,"Gold PPO, Family Plan - Silver EPO",80.00,185.00,0.00,4.50,269.50,4.5,"Annual Physical Exam performed at Green Valley Clinic."
Maria Lopez,Daughter,19,Female,Blood Test,diagnostic,City Hospital,2025-02-14,true,Family Plan - Silver EPO,"Gold PPO, Medicare Advantage",28.00,55.00,0.00,3.00,86.00,4.7,"Iron panel performed at City Hospital."
Maria Lopez,Daughter,19,Female,Vision Exam,preventive,Sunrise Health,2024-08-20,true,Family Plan - Silver EPO,"Gold PPO, Bronze HDHP",38.00,108.00,0.00,0.00,146.00,4.3,"Vision Exam performed at Sunrise Health."
  • Step 8: Write app/data/available_procedures.csv

28 rows covering MRI, CT Scan, X Ray, Annual Physical Exam, Appendectomy, Dental Cleaning, Vision Exam, Blood Test, Angioplasty, Ultrasound across 4 locations:

Columns: procedure,location,facility_rating,distance_miles,gold_ppo_plan_accepted,silver_epo_plan_accepted,accepted_plans,cost_facility,cost_physician,cost_anesthesia,cost_medication,total_cost

procedure,location,facility_rating,distance_miles,gold_ppo_plan_accepted,silver_epo_plan_accepted,accepted_plans,cost_facility,cost_physician,cost_anesthesia,cost_medication,total_cost
MRI,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",1450.00,820.00,0.00,12.00,2282.00
MRI,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",1380.00,790.00,0.00,11.00,2181.00
MRI,Green Valley Clinic,4.5,8.4,true,false,"Gold PPO, Bronze HDHP",1520.00,860.00,0.00,12.50,2392.50
MRI,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",1610.00,880.00,0.00,13.00,2503.00
CT Scan,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",1600.00,750.00,0.00,8.00,2358.00
CT Scan,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",1540.00,720.00,0.00,7.50,2267.50
CT Scan,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",1750.00,820.00,0.00,9.00,2579.00
X Ray,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",110.00,140.00,0.00,2.00,252.00
X Ray,Green Valley Clinic,4.5,8.4,true,true,"Gold PPO, Family Plan - Silver EPO, Bronze HDHP",95.00,125.00,0.00,1.50,221.50
X Ray,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",90.00,120.00,0.00,1.50,211.50
X Ray,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",100.00,130.00,0.00,1.50,231.50
Annual Physical Exam,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",80.00,180.00,0.00,5.00,265.00
Annual Physical Exam,Green Valley Clinic,4.5,8.4,true,true,"Gold PPO, Family Plan - Silver EPO, Bronze HDHP",75.00,170.00,0.00,4.50,249.50
Annual Physical Exam,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",85.00,200.00,0.00,4.50,289.50
Annual Physical Exam,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",90.00,210.00,0.00,5.00,305.00
Appendectomy,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",9100.00,5800.00,2200.00,195.00,17295.00
Appendectomy,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",8200.00,5400.00,2100.00,180.00,15880.00
Dental Cleaning,Green Valley Clinic,4.5,8.4,true,true,"Gold PPO, Family Plan - Silver EPO, Bronze HDHP",58.00,118.00,0.00,0.00,176.00
Dental Cleaning,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO",60.00,120.00,0.00,0.00,180.00
Dental Cleaning,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",55.00,115.00,0.00,0.00,170.00
Vision Exam,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",35.00,105.00,0.00,0.00,140.00
Vision Exam,Green Valley Clinic,4.5,8.4,true,true,"Gold PPO, Family Plan - Silver EPO, Bronze HDHP",38.00,108.00,0.00,0.00,146.00
Vision Exam,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO",40.00,110.00,0.00,0.00,150.00
Blood Test,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",25.00,55.00,0.00,2.50,82.50
Blood Test,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",28.00,55.00,0.00,3.00,86.00
Angioplasty,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",9432.80,4774.57,1894.37,834.80,16936.54
Angioplasty,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",8920.00,4520.00,1850.00,810.00,16100.00
Ultrasound,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",380.00,290.00,0.00,3.00,673.00
  • Step 9: Create app/data/reports_output/.gitkeep

Empty file. Run:

mkdir -p app/data/reports_output && touch app/data/reports_output/.gitkeep
  • Step 10: Sanity-check the CSVs load with pandas

Run a quick REPL check:

python3.11 -c "
import pandas as pd
h = pd.read_csv('app/data/historical_procedures.csv')
a = pd.read_csv('app/data/available_procedures.csv')
c = pd.read_csv('app/data/aetna_claim_review_summary.csv')
print('historical:', len(h), 'rows,', list(h.columns)[:5], '...')
print('available:', len(a), 'rows,', list(a.columns)[:5], '...')
print('aetna:', len(c), 'rows,', list(c.columns)[:5], '...')
charlie = h[h['member_name'] == 'Charlie Smith']
print('Charlie rows:', len(charlie))
assert len(charlie) >= 4, 'Charlie should have ≥4 rows (matches member_insights overdue list)'
"

Expected: historical: 50 rows, available: 28 rows, aetna: 10 rows, Charlie rows: 5.

  • Step 11: Commit
git add app/data/
git commit -m "feat(data): synthetic datasets for the 5 workshop endpoints"

Task 6: Endpoints 3 (member-insights) and 4 (schedule)

These are static — no logic. Implementing them first builds confidence in the routing setup.

Files:

  • Create: app/benefits_api.py

  • Modify: app/main.py

  • Create: tests/test_benefits_api.py

  • Step 1: Write failing tests

# tests/test_benefits_api.py
def test_member_insights_returns_static_object(client):
    response = client.get("/api/member-insights")
    assert response.status_code == 200
    body = response.json()
    assert "result" in body
    assert body["result"]["member"]["name"] == "Charlie Smith"
    assert body["result"]["member"]["plan"] == "Gold PPO"
    assert len(body["result"]["overdue_procedures"]) == 4


def test_schedule_returns_text_wrapped(client):
    response = client.get("/api/schedule")
    assert response.status_code == 200
    body = response.json()
    assert "result" in body
    assert "agendar una cita" in body["result"].lower()
    assert "1-800-FIT-CARE" in body["result"]
  • Step 2: Run tests to verify they fail
pytest tests/test_benefits_api.py -v

Expected: 404 (routes don't exist yet).

  • Step 3: Implement app/benefits_api.py
import json
from pathlib import Path

from fastapi import APIRouter

router = APIRouter(prefix="/api", tags=["benefits"])

_DATA_DIR = Path(__file__).parent / "data"

with (_DATA_DIR / "member_insights.json").open() as f:
    _MEMBER_INSIGHTS = json.load(f)

_SCHEDULE_TEXT = (_DATA_DIR / "schedule_response.txt").read_text(encoding="utf-8").strip()


@router.get("/member-insights")
def member_insights():
    return _MEMBER_INSIGHTS


@router.get("/schedule")
def schedule():
    return {"result": _SCHEDULE_TEXT}
  • Step 4: Wire router into app/main.py

Add to imports:

from app import benefits_api

Add after app.add_middleware(...):

app.include_router(benefits_api.router)
  • Step 5: Run tests to verify they pass
pytest tests/test_benefits_api.py -v

Expected: 2 passed.

  • Step 6: Commit
git add app/benefits_api.py app/main.py tests/test_benefits_api.py
git commit -m "feat(api): endpoints 3 and 4 - member-insights and schedule"

Task 7: Endpoint 1 (historical-procedures)

Adds the filter + group_by logic. Shared with endpoint 2 in Task 8.

Files:

  • Modify: app/benefits_api.py

  • Modify: tests/test_benefits_api.py

  • Step 1: Write failing tests

Append to tests/test_benefits_api.py:

def test_historical_procedures_no_filter_returns_all(client):
    response = client.post(
        "/api/historical-procedures",
        json={"filters": "[]", "group_by": "[]"},
    )
    assert response.status_code == 200
    body = response.json()
    assert "result" in body
    assert len(body["result"]) == 50


def test_historical_procedures_filter_equals(client):
    response = client.post(
        "/api/historical-procedures",
        json={
            "filters": '[{"column": "member_name", "operator": "equals", "value": "Charlie Smith"}]',
            "group_by": "[]",
        },
    )
    assert response.status_code == 200
    rows = response.json()["result"]
    assert len(rows) == 5  # Charlie Smith has 5 rows in the dataset
    assert all(r["member_name"] == "Charlie Smith" for r in rows)


def test_historical_procedures_filter_contains(client):
    response = client.post(
        "/api/historical-procedures",
        json={
            "filters": '[{"column": "procedure", "operator": "contains", "value": "mri"}]',
            "group_by": "[]",
        },
    )
    assert response.status_code == 200
    rows = response.json()["result"]
    assert len(rows) > 0
    assert all("MRI" in r["procedure"] for r in rows)


def test_historical_procedures_filter_gt(client):
    response = client.post(
        "/api/historical-procedures",
        json={
            "filters": '[{"column": "total_cost", "operator": "gt", "value": 5000}]',
            "group_by": "[]",
        },
    )
    assert response.status_code == 200
    rows = response.json()["result"]
    assert len(rows) > 0
    assert all(r["total_cost"] > 5000 for r in rows)


def test_historical_procedures_group_by(client):
    response = client.post(
        "/api/historical-procedures",
        json={"filters": "[]", "group_by": '["relationship"]'},
    )
    assert response.status_code == 200
    rows = response.json()["result"]
    assert len(rows) > 0
    # group_by should leave only group key + aggregated numeric cols
    assert "relationship" in rows[0]
    assert "age" in rows[0]
  • Step 2: Run tests — expect failures
pytest tests/test_benefits_api.py -v

Expected: 4 new tests fail (404).

  • Step 3: Implement filter+group_by helper and route

Replace app/benefits_api.py with:

import json
from pathlib import Path

import pandas as pd
from fastapi import APIRouter, HTTPException

router = APIRouter(prefix="/api", tags=["benefits"])

_DATA_DIR = Path(__file__).parent / "data"

with (_DATA_DIR / "member_insights.json").open() as f:
    _MEMBER_INSIGHTS = json.load(f)

_SCHEDULE_TEXT = (_DATA_DIR / "schedule_response.txt").read_text(encoding="utf-8").strip()

_HISTORICAL = pd.read_csv(_DATA_DIR / "historical_procedures.csv")


_OPS = {
    "equals": lambda s, v: s == v,
    "ne": lambda s, v: s != v,
    "contains": lambda s, v: s.astype(str).str.contains(str(v), case=False, na=False),
    "gt": lambda s, v: s > v,
    "lt": lambda s, v: s < v,
    "ge": lambda s, v: s >= v,
    "le": lambda s, v: s <= v,
}


def _apply_filters_and_group(df: pd.DataFrame, filters_raw: str, group_by_raw: str) -> list[dict]:
    try:
        filters = json.loads(filters_raw) if filters_raw else []
        group_by = json.loads(group_by_raw) if group_by_raw else []
    except json.JSONDecodeError as exc:
        raise HTTPException(status_code=400, detail=f"Invalid JSON in filters or group_by: {exc}")

    result = df.copy()
    for f in filters:
        col, op, val = f.get("column"), f.get("operator"), f.get("value")
        if col not in result.columns:
            raise HTTPException(status_code=400, detail=f"Unknown column: {col}")
        if op not in _OPS:
            raise HTTPException(status_code=400, detail=f"Unsupported operator: {op}")
        result = result[_OPS[op](result[col], val)]

    if group_by:
        missing = [c for c in group_by if c not in result.columns]
        if missing:
            raise HTTPException(status_code=400, detail=f"Unknown group_by columns: {missing}")
        numeric_cols = result.select_dtypes(include="number").columns.tolist()
        result = result.groupby(group_by)[numeric_cols].mean().reset_index()

    return result.to_dict(orient="records")


@router.get("/member-insights")
def member_insights():
    return _MEMBER_INSIGHTS


@router.get("/schedule")
def schedule():
    return {"result": _SCHEDULE_TEXT}


@router.post("/historical-procedures")
def historical_procedures(payload: dict):
    rows = _apply_filters_and_group(
        _HISTORICAL,
        payload.get("filters", "[]"),
        payload.get("group_by", "[]"),
    )
    return {"result": rows}
  • Step 4: Run tests — expect pass
pytest tests/test_benefits_api.py -v

Expected: 7 passed.

  • Step 5: Commit
git add app/benefits_api.py tests/test_benefits_api.py
git commit -m "feat(api): endpoint 1 - historical-procedures with filter+group_by"

Task 8: Endpoint 2 (available-procedures)

Reuses the helper from Task 7.

Files:

  • Modify: app/benefits_api.py

  • Modify: tests/test_benefits_api.py

  • Step 1: Write failing tests

Append to tests/test_benefits_api.py:

def test_available_procedures_no_filter(client):
    response = client.post(
        "/api/available-procedures",
        json={"filters": "[]", "group_by": "[]"},
    )
    assert response.status_code == 200
    rows = response.json()["result"]
    assert len(rows) == 28


def test_available_procedures_filter_mri(client):
    response = client.post(
        "/api/available-procedures",
        json={
            "filters": '[{"column": "procedure", "operator": "contains", "value": "MRI"}]',
            "group_by": "[]",
        },
    )
    rows = response.json()["result"]
    assert len(rows) >= 3
    assert all(r["procedure"] == "MRI" for r in rows)


def test_available_procedures_group_by_procedure(client):
    response = client.post(
        "/api/available-procedures",
        json={"filters": "[]", "group_by": '["procedure"]'},
    )
    rows = response.json()["result"]
    assert len(rows) > 0
    assert "procedure" in rows[0]
    assert "total_cost" in rows[0]  # numeric column should be averaged
  • Step 2: Run tests — expect 404
pytest tests/test_benefits_api.py -v

Expected: 3 new tests fail (404).

  • Step 3: Add the route to app/benefits_api.py

After the _HISTORICAL = ... line, add:

_AVAILABLE = pd.read_csv(_DATA_DIR / "available_procedures.csv")

Append at the end of the file:

@router.post("/available-procedures")
def available_procedures(payload: dict):
    rows = _apply_filters_and_group(
        _AVAILABLE,
        payload.get("filters", "[]"),
        payload.get("group_by", "[]"),
    )
    return {"result": rows}
  • Step 4: Run tests — expect pass
pytest tests/test_benefits_api.py -v

Expected: 10 passed.

  • Step 5: Commit
git add app/benefits_api.py tests/test_benefits_api.py
git commit -m "feat(api): endpoint 2 - available-procedures (reuses filter helper)"

Task 9: Endpoint 5 (generate-report)

The trickiest. Generates HTML with Jinja2 + Plotly, writes to disk, returns public URL.

Files:

  • Create: app/reports_api.py

  • Create: app/templates/report.html

  • Modify: app/main.py

  • Create: tests/test_reports_api.py

  • Step 1: Write app/templates/report.html

<!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', 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>
  • Step 2: Write failing tests
# tests/test_reports_api.py
import json
import os
from pathlib import Path


def test_generate_report_with_care_report_preset(client):
    response = client.post(
        "/api/reports/generate-report",
        json={"layout_config": json.dumps(["care_report"])},
    )
    assert response.status_code == 200
    body = response.json()
    assert "public_url" in body
    assert body["public_url"].endswith(".html")

    # Get the report id and verify the file was written
    report_id = body["public_url"].rsplit("/", 1)[-1]
    output_dir = Path(os.environ["REPORTS_OUTPUT_DIR"])
    assert (output_dir / report_id).exists()
    content = (output_dir / report_id).read_text()
    assert "Care Report" in content
    assert "Customer Overview" in content
    assert "Claim Review Summary" in content
    assert "<table" in content


def test_generate_report_with_custom_layout(client):
    layout = [
        {"element_type": "header", "parameters": {"title": "Quarterly Recap"}},
        {
            "element_type": "table",
            "parameters": {
                "csv_file": "./data/aetna_claim_review_summary.csv",
                "title": "Claims",
            },
        },
    ]
    response = client.post(
        "/api/reports/generate-report",
        json={"layout_config": json.dumps(layout)},
    )
    assert response.status_code == 200
    report_id = response.json()["public_url"].rsplit("/", 1)[-1]
    output_dir = Path(os.environ["REPORTS_OUTPUT_DIR"])
    content = (output_dir / report_id).read_text()
    assert "Quarterly Recap" in content
    assert "Claims" in content


def test_generate_report_served_as_static(client):
    response = client.post(
        "/api/reports/generate-report",
        json={"layout_config": json.dumps(["care_report"])},
    )
    report_id = response.json()["public_url"].rsplit("/", 1)[-1]
    # The static mount should serve it
    served = client.get(f"/api/reports/output/{report_id}")
    assert served.status_code == 200
    assert "text/html" in served.headers["content-type"]
  • Step 3: Run tests — expect 404
pytest tests/test_reports_api.py -v

Expected: routes don't exist, 404s.

  • Step 4: Implement app/reports_api.py
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:
    """Resolve a path provided by the agent. The spec sends them like './data/foo.txt'."""
    cleaned = relative.lstrip("./")
    if cleaned.startswith("data/"):
        cleaned = cleaned[len("data/"):]
    return _DATA_DIR / cleaned


def _render_header(params: dict) -> str:
    title = params.get("title", "")
    return f"<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")
    # Render text with line breaks. No LLM dependency — show raw + a synthesized summary heading.
    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:
    # Use the CSV to build a bar chart of charged vs allowed vs patient_responsibility per CPT
    df = pd.read_csv(_DATA_DIR / "aetna_claim_review_summary.csv")
    fig = px.bar(
        df,
        x="CPT_Code",
        y=["Charged_Amount", "Allowed_Amount", "Patient_Responsibility"],
        barmode="group",
        title="Claim Review by CPT Code",
        color_discrete_sequence=["#0A1F44", "#1E4FA8", "#FF7A00"],
    )
    return pio.to_html(fig, full_html=False, include_plotlyjs=False)


def _render_table(params: dict) -> str:
    title = params.get("title", "")
    csv_file = _resolve_path(params.get("csv_file", ""))
    if not csv_file.exists():
        raise HTTPException(status_code=400, detail=f"csv_file not found: {params.get('csv_file')}")
    df = pd.read_csv(csv_file)
    return f"<h2>{title}</h2>" + df.to_html(index=False, classes="report-table", border=0)


_RENDERERS = {
    "header": _render_header,
    "overview": _render_overview,
    "claim_review_chart": lambda _params: _render_claim_chart(),
    "table": _render_table,
}


@router.post("/generate-report")
def generate_report(payload: dict):
    try:
        layout = json.loads(payload["layout_config"])
    except (KeyError, json.JSONDecodeError) as exc:
        raise HTTPException(status_code=400, detail=f"Invalid layout_config: {exc}")

    # Expand preset strings
    expanded: list[dict] = []
    for item in layout:
        if item == "care_report":
            expanded.extend(CARE_REPORT_PRESET)
        elif isinstance(item, dict):
            expanded.append(item)
        else:
            raise HTTPException(status_code=400, detail=f"Unknown layout item: {item!r}")

    parts: list[str] = []
    for el in expanded:
        kind = el.get("element_type")
        if kind not in _RENDERERS:
            raise HTTPException(status_code=400, detail=f"Unknown element_type: {kind}")
        parts.append(_RENDERERS[kind](el.get("parameters", {})))

    template = _env.get_template("report.html")
    html = template.render(content="\n".join(parts))

    settings = get_settings()
    output_dir = Path(settings.reports_output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)
    report_id = f"{uuid.uuid4().hex[:12]}.html"
    (output_dir / report_id).write_text(html, encoding="utf-8")

    return {"public_url": f"{settings.base_url}/api/reports/output/{report_id}"}
  • Step 5: Wire router and static mount into app/main.py

Add imports:

from pathlib import Path

from fastapi.staticfiles import StaticFiles

from app import reports_api

After the existing app.include_router(...), add:

app.include_router(reports_api.router)

# Serve generated reports as static files
_reports_dir = Path(settings.reports_output_dir)
_reports_dir.mkdir(parents=True, exist_ok=True)
app.mount(
    "/api/reports/output",
    StaticFiles(directory=str(_reports_dir)),
    name="reports_output",
)
  • Step 6: Run tests — expect pass
pytest tests/test_reports_api.py -v

Expected: 3 passed.

  • Step 7: Quick manual smoke
uvicorn app.main:app --reload --port 8000

In another terminal:

curl -X POST http://localhost:8000/api/reports/generate-report \
  -H "Content-Type: application/json" \
  -d '{"layout_config":"[\"care_report\"]"}'

Expected: JSON with a public_url ending in .html. Open the URL in a browser to verify the report renders with the chart.

  • Step 8: Commit
git add app/reports_api.py app/templates/report.html app/main.py tests/test_reports_api.py
git commit -m "feat(api): endpoint 5 - generate-report with jinja2 + plotly"

Task 10: Frontend templates and CSS (no backend routes yet)

This task produces the HTML/CSS skeleton. Backend routes that serve them come in Task 11.

Files:

  • Create: app/templates/base.html

  • Create: app/templates/index.html

  • Create: app/templates/descargas.html

  • Create: static/css/styles.css

  • Create: static/js/app.js

  • Create: static/img/.gitkeep

  • Step 1: Compress the existing logo

mkdir -p static/img
# Copy first; compression is a nice-to-have, the PNG works as-is
cp images/LogoFIT.png static/img/LogoFIT.png

(If you have pngquant or similar installed, run pngquant --quality=70-85 -o static/img/LogoFIT.png --force static/img/LogoFIT.png to compress. Otherwise skip — the 971 KB PNG is acceptable for Thursday.)

  • Step 2: Write app/templates/base.html
<!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="32"></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><a href="https://github.com/leozangulo/agentic-bootcamp" target="_blank" rel="noopener">github.com/leozangulo/agentic-bootcamp</a></p>
      <p class="copyright">© 2026 FactorIT — Todos los derechos reservados</p>
    </div>
  </footer>

  <script src="/static/js/app.js" defer></script>
</body>
</html>
  • Step 3: Write app/templates/index.html
{% 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>
    <!-- honeypot — invisible to humans, attractive to bots -->
    <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 %}
  • Step 4: Write app/templates/descargas.html
{% 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">{{ d.size_mb }} MB</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 %}
  • Step 5: Write static/css/styles.css
:root {
  --navy: #0A1F44;
  --blue: #1E4FA8;
  --cyan: #00B5D8;
  --orange: #FF7A00;
  --orange-hover: #E66A00;
  --cream: #F5F7FA;
  --text: #1A1A1A;
  --muted: #5A6473;
  --border: #D8DEE5;
}

* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; font-family: 'Inter', system-ui, sans-serif; color: var(--text); background: white; line-height: 1.6; }

.site-header {
  display: flex; align-items: center; justify-content: space-between;
  padding: 16px 32px; background: white; border-bottom: 1px solid var(--border);
}
.site-header .brand img { display: block; }
.powered-by { color: var(--muted); font-size: 13px; }
.powered-by strong { color: var(--navy); }

.hero {
  background: linear-gradient(135deg, var(--navy) 0%, var(--blue) 100%);
  color: white; padding: 80px 32px; min-height: 80vh; display: flex; align-items: center;
}
.hero-inner { max-width: 800px; margin: 0 auto; text-align: center; }
.eyebrow { color: var(--cyan); font-weight: 700; letter-spacing: 2px; margin: 0 0 16px; }
.hero h1 { font-size: clamp(32px, 5vw, 56px); margin: 0 0 16px; font-weight: 800; line-height: 1.15; }
.subtitle { font-size: 20px; color: var(--cream); font-style: italic; margin: 0 0 32px; }

.btn { display: inline-block; padding: 14px 28px; border-radius: 8px; font-weight: 700; text-decoration: none; cursor: pointer; border: 0; font-size: 16px; transition: transform 0.15s ease, background 0.15s ease; }
.btn-primary { background: var(--orange); color: white; }
.btn-primary:hover { background: var(--orange-hover); transform: translateY(-1px); }
.btn-large { padding: 18px 36px; font-size: 18px; }
.btn-disabled { background: var(--border); color: var(--muted); cursor: not-allowed; }

.section-title { text-align: center; font-size: 32px; color: var(--navy); margin: 0 0 32px; }

.cards-section, .stats-section, .form-section, .descargas-section {
  padding: 64px 32px; max-width: 1100px; margin: 0 auto;
}
.cards-section { background: var(--cream); max-width: none; }
.cards-section .section-title, .cards-section .cards { max-width: 1100px; margin-left: auto; margin-right: auto; }

.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 24px; }
.card { background: white; padding: 32px; border-radius: 12px; box-shadow: 0 2px 8px rgba(10, 31, 68, 0.08); transition: transform 0.2s ease, box-shadow 0.2s ease; }
.card:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(10, 31, 68, 0.14); }
.card .icon { font-size: 36px; margin-bottom: 12px; }
.card h3 { color: var(--navy); margin: 0 0 8px; }
.card p { color: var(--muted); margin: 0; }

.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 24px; text-align: center; }
.stat { padding: 24px; }
.stat .num { display: block; font-size: 56px; font-weight: 800; color: var(--orange); line-height: 1; }
.stat .label { display: block; color: var(--muted); margin-top: 8px; font-size: 14px; text-transform: uppercase; letter-spacing: 1px; }

.form-section { background: white; max-width: 600px; }
.form-intro { text-align: center; color: var(--muted); margin: 0 0 32px; }
.register-form { display: flex; flex-direction: column; gap: 20px; }
.field label { display: block; font-weight: 600; margin-bottom: 6px; color: var(--navy); }
.field input[type="text"], .field input[type="email"] {
  width: 100%; padding: 12px 14px; border: 2px solid var(--border); border-radius: 8px; font-size: 16px; font-family: inherit;
}
.field input:focus { outline: none; border-color: var(--cyan); }
.field-checkbox { display: flex; gap: 12px; align-items: flex-start; }
.field-checkbox input { margin-top: 4px; }
.field-checkbox label { font-weight: 400; color: var(--muted); font-size: 14px; }
.hp-field { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden; }

.banner { padding: 16px 32px; text-align: center; }
.banner-error { background: #FFF1F0; color: #B0211A; border-bottom: 2px solid #B0211A; }

.descargas-section { text-align: center; }
.hello { color: var(--navy); font-size: 40px; margin: 0 0 8px; }
.hello-sub { color: var(--muted); margin: 0 0 48px; }
.download-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 32px; }
.download-card { background: var(--cream); padding: 40px; border-radius: 16px; border: 2px solid transparent; transition: border-color 0.2s ease, transform 0.2s ease; }
.download-card:hover { border-color: var(--cyan); transform: translateY(-2px); }
.download-card .dl-icon { font-size: 56px; margin-bottom: 16px; }
.download-card h2 { color: var(--navy); margin: 0 0 8px; font-size: 24px; }
.download-card p { color: var(--muted); margin: 0 0 8px; }
.download-card .size { font-size: 13px; color: var(--muted); font-weight: 600; }

.site-footer { background: var(--navy); color: white; padding: 32px; margin-top: 64px; }
.footer-inner { max-width: 1100px; margin: 0 auto; text-align: center; }
.footer-inner a { color: var(--cyan); text-decoration: none; }
.copyright { color: rgba(255, 255, 255, 0.5); font-size: 13px; margin-top: 16px; }

@media (max-width: 768px) {
  .hero { min-height: 70vh; padding: 48px 24px; }
  .cards-section, .stats-section, .form-section, .descargas-section { padding: 48px 24px; }
  .download-card { padding: 24px; }
}
  • Step 6: Write static/js/app.js (minimal)
// Smooth scroll for in-page anchors is already handled by html { scroll-behavior: smooth }.
// Light enhancement: fade-in sections as they enter the viewport.

document.addEventListener('DOMContentLoaded', () => {
  const sections = document.querySelectorAll('.cards-section, .stats-section, .form-section');
  if (!('IntersectionObserver' in window)) return;
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        entry.target.style.opacity = '1';
        entry.target.style.transform = 'translateY(0)';
        observer.unobserve(entry.target);
      }
    });
  }, { threshold: 0.1 });

  sections.forEach(s => {
    s.style.opacity = '0';
    s.style.transform = 'translateY(20px)';
    s.style.transition = 'opacity 0.6s ease, transform 0.6s ease';
    observer.observe(s);
  });
});
  • Step 7: Create gitkeep for static/img
mkdir -p static/img && touch static/img/.gitkeep
  • Step 8: Commit (no tests yet — wired up in Task 11)
git add app/templates/ static/
git commit -m "feat(frontend): templates (base, index, descargas) + FIT-branded CSS + light JS"

Task 11: Frontend routes (landing, register, descargas, download)

Files:

  • Create: app/frontend.py

  • Modify: app/main.py

  • Create: tests/test_frontend.py

  • Step 1: Write failing tests

# tests/test_frontend.py
import os
from pathlib import Path


def test_landing_renders(client):
    response = client.get("/")
    assert response.status_code == 200
    assert "text/html" in response.headers["content-type"]
    assert "Bootcamp Agentic AI" in response.text
    assert 'name="email"' in response.text
    assert 'name="website"' in response.text  # honeypot present


def test_register_valid_form_redirects_with_token(client):
    response = client.post(
        "/register",
        data={
            "nombre": "Felipe Arentsen",
            "email": "felipe@factorit.com",
            "empresa": "FactorIT",
            "consentimiento": "on",
        },
        follow_redirects=False,
    )
    assert response.status_code == 303
    location = response.headers["location"]
    assert location.startswith("/descargas?token=")


def test_register_missing_consent_fails(client):
    response = client.post(
        "/register",
        data={
            "nombre": "Felipe",
            "email": "f@x.com",
            "empresa": "X",
        },
        follow_redirects=False,
    )
    assert response.status_code == 422 or response.status_code == 400


def test_register_honeypot_filled_silently_drops(client):
    response = client.post(
        "/register",
        data={
            "nombre": "Bot",
            "email": "bot@spam.com",
            "empresa": "Spam Inc",
            "consentimiento": "on",
            "website": "https://spam.com",
        },
        follow_redirects=False,
    )
    # Honeypot: return a believable redirect but DON'T persist
    assert response.status_code == 303
    # Verify nothing was saved
    from app.db import get_lead_by_email
    assert get_lead_by_email("bot@spam.com") is None


def test_register_duplicate_email_reissues_token(client):
    for _ in range(2):
        client.post(
            "/register",
            data={
                "nombre": "Felipe",
                "email": "dup@factorit.com",
                "empresa": "FactorIT",
                "consentimiento": "on",
            },
            follow_redirects=False,
        )
    from app.db import get_lead_by_email
    lead = get_lead_by_email("dup@factorit.com")
    assert lead["times_registered"] == 2


def test_descargas_with_invalid_token_redirects_home(client):
    response = client.get("/descargas?token=invalid-junk", follow_redirects=False)
    assert response.status_code == 307
    assert response.headers["location"].startswith("/?error=")


def test_descargas_with_valid_token_renders(client):
    reg = client.post(
        "/register",
        data={
            "nombre": "Maria",
            "email": "maria@test.com",
            "empresa": "Test Co",
            "consentimiento": "on",
        },
        follow_redirects=False,
    )
    token = reg.headers["location"].split("token=")[1]
    response = client.get(f"/descargas?token={token}")
    assert response.status_code == 200
    assert "¡Hola Maria!" in response.text
    assert "Material técnico" in response.text
    assert "Material funcional" in response.text


def test_download_with_valid_token_serves_file(client, tmp_path):
    # Put a fake zip in the configured material dir
    material_dir = Path(os.environ["MATERIAL_DIR"])
    material_dir.mkdir(parents=True, exist_ok=True)
    (material_dir / "taller-wox-tecnico.zip").write_bytes(b"PK\x03\x04 fake zip")

    reg = client.post(
        "/register",
        data={
            "nombre": "Test",
            "email": "test-dl@test.com",
            "empresa": "TC",
            "consentimiento": "on",
        },
        follow_redirects=False,
    )
    token = reg.headers["location"].split("token=")[1]
    response = client.get(f"/download/taller-wox-tecnico.zip?token={token}")
    assert response.status_code == 200
    assert response.content == b"PK\x03\x04 fake zip"
    assert "attachment" in response.headers["content-disposition"]


def test_download_with_invalid_filename_returns_404(client):
    reg = client.post(
        "/register",
        data={
            "nombre": "Test",
            "email": "bad-fn@test.com",
            "empresa": "TC",
            "consentimiento": "on",
        },
        follow_redirects=False,
    )
    token = reg.headers["location"].split("token=")[1]
    response = client.get(f"/download/../../etc/passwd?token={token}")
    assert response.status_code == 404
  • Step 2: Run tests — expect 404s on all routes
pytest tests/test_frontend.py -v

Expected: failures because routes don't exist yet.

  • Step 3: Implement app/frontend.py
from pathlib import Path

from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from itsdangerous import BadSignature, SignatureExpired
from pydantic import EmailStr

from app.config import get_settings
from app.db import init_db, upsert_lead, log_download
from app.security import (
    create_download_token,
    verify_download_token,
    is_honeypot_filled,
)

router = APIRouter()

_TEMPLATES_DIR = Path(__file__).parent / "templates"
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))

# Whitelist of downloadable filenames — guards against path traversal
DOWNLOADS = [
    {
        "filename": "taller-wox-tecnico.zip",
        "icon": "🧩",
        "title": "Material técnico",
        "description": "Specs OpenAPI, configs y artefactos para importar a watsonx Orchestrate.",
    },
    {
        "filename": "taller-wox-funcional.zip",
        "icon": "📚",
        "title": "Material funcional",
        "description": "Manual paso a paso del bootcamp y deck de slides.",
    },
]
ALLOWED_FILENAMES = {d["filename"] for d in DOWNLOADS}


# Ensure DB exists at startup (called from main.py)
def ensure_db_initialized() -> None:
    init_db()


@router.get("/", response_class=HTMLResponse)
def landing(request: Request, error: str | None = None):
    error_msg = None
    if error == "token-invalido":
        error_msg = "El link expiró o es inválido. Regístrate de nuevo para descargar el material."
    return templates.TemplateResponse(
        "index.html",
        {"request": request, "error": error_msg},
    )


@router.post("/register")
def register(
    request: Request,
    nombre: str = Form(..., min_length=2, max_length=80),
    email: EmailStr = Form(...),
    empresa: str = Form(..., min_length=2, max_length=100),
    consentimiento: str = Form(...),  # presence required
    website: str | None = Form(default=""),  # honeypot
):
    if consentimiento != "on":
        raise HTTPException(status_code=400, detail="Consentimiento requerido")

    # Honeypot filled → fake-success without persisting
    if is_honeypot_filled(website):
        fake_token = create_download_token(email="honeypot@discarded.local", nombre="x")
        return RedirectResponse(url=f"/descargas?token={fake_token}", status_code=303)

    client_ip = request.client.host if request.client else None
    user_agent = request.headers.get("user-agent")

    upsert_lead(
        nombre=nombre,
        email=str(email),
        empresa=empresa,
        ip=client_ip,
        user_agent=user_agent,
        consent=True,
    )

    token = create_download_token(email=str(email), nombre=nombre)
    return RedirectResponse(url=f"/descargas?token={token}", status_code=303)


@router.get("/descargas", response_class=HTMLResponse)
def descargas(request: Request, token: str | None = None):
    if not token:
        return RedirectResponse(url="/?error=token-invalido", status_code=307)
    try:
        data = verify_download_token(token)
    except (SignatureExpired, BadSignature):
        return RedirectResponse(url="/?error=token-invalido", status_code=307)

    settings = get_settings()
    material_dir = Path(settings.material_dir)
    downloads_view = []
    for d in DOWNLOADS:
        path = material_dir / d["filename"]
        downloads_view.append({
            **d,
            "available": path.exists(),
            "size_mb": round(path.stat().st_size / (1024 * 1024), 1) if path.exists() else 0,
        })

    return templates.TemplateResponse(
        "descargas.html",
        {
            "request": request,
            "nombre": data.get("nombre", "amig@"),
            "token": token,
            "downloads": downloads_view,
        },
    )


@router.get("/download/{filename}")
def download(request: Request, filename: str, token: str | None = None):
    if not token:
        raise HTTPException(status_code=401, detail="Missing token")
    try:
        data = verify_download_token(token)
    except (SignatureExpired, BadSignature):
        raise HTTPException(status_code=401, detail="Invalid or expired token")

    if filename not in ALLOWED_FILENAMES:
        raise HTTPException(status_code=404, detail="File not found")

    path = Path(get_settings().material_dir) / filename
    if not path.exists():
        raise HTTPException(status_code=404, detail="File not available yet")

    client_ip = request.client.host if request.client else None
    log_download(lead_email=data["email"], filename=filename, ip=client_ip)

    return FileResponse(
        path=str(path),
        filename=filename,
        media_type="application/zip",
    )
  • Step 4: Wire static mount + frontend router + DB init into app/main.py

Replace app/main.py with:

from pathlib import Path

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles

from app import benefits_api, frontend, reports_api
from app.config import get_settings
from app.db import init_db

settings = get_settings()

app = FastAPI(title="taller-wox.fitlabs.dev", version="1.0.0")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=False,
    allow_methods=["GET", "POST"],
    allow_headers=["*"],
)

# Static assets (CSS, JS, images)
_STATIC_DIR = Path(__file__).parent.parent / "static"
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")

# Routers
app.include_router(frontend.router)
app.include_router(benefits_api.router)
app.include_router(reports_api.router)

# Generated reports served as static
_reports_dir = Path(settings.reports_output_dir)
_reports_dir.mkdir(parents=True, exist_ok=True)
app.mount(
    "/api/reports/output",
    StaticFiles(directory=str(_reports_dir)),
    name="reports_output",
)


@app.on_event("startup")
def on_startup() -> None:
    init_db()


@app.get("/health")
def health():
    return {"status": "ok", "base_url": settings.base_url}
  • Step 5: Run tests — expect pass
pytest tests/test_frontend.py -v

Expected: 9 passed.

  • Step 6: Full test suite check
pytest -v

Expected: all tests pass.

  • Step 7: Manual smoke (browser)
uvicorn app.main:app --reload --port 8000

Open http://localhost:8000/. Verify:

  • Landing renders with FIT branding (navy hero, orange CTA)
  • Form has all 4 fields + invisible honeypot
  • Submit redirects to /descargas?token=...
  • Descargas page shows 2 cards (both marked "No disponible" since material/ is empty in dev)
  • Hitting /descargas?token=garbage redirects home with banner

Stop the server.

  • Step 8: Commit
git add app/frontend.py app/main.py tests/test_frontend.py
git commit -m "feat(frontend): landing, registration with honeypot, descargas with token, gated download"

Task 12: Admin endpoints

Files:

  • Create: app/admin.py

  • Modify: app/main.py

  • Create: tests/test_admin.py

  • Step 1: Write failing tests

# tests/test_admin.py
import base64


def _auth_header(user: str = "testadmin", password: str = "testpass") -> dict:
    token = base64.b64encode(f"{user}:{password}".encode()).decode()
    return {"Authorization": f"Basic {token}"}


def test_admin_leads_requires_auth(client):
    response = client.get("/admin/leads.json")
    assert response.status_code == 401


def test_admin_leads_with_bad_credentials_rejected(client):
    response = client.get("/admin/leads.json", headers=_auth_header(password="wrong"))
    assert response.status_code == 401


def test_admin_leads_json_returns_list(client):
    client.post(
        "/register",
        data={"nombre": "A", "email": "a@a.com", "empresa": "ACo", "consentimiento": "on"},
    )
    response = client.get("/admin/leads.json", headers=_auth_header())
    assert response.status_code == 200
    body = response.json()
    assert isinstance(body, list)
    assert any(l["email"] == "a@a.com" for l in body)


def test_admin_leads_csv_returns_csv(client):
    client.post(
        "/register",
        data={"nombre": "B", "email": "b@b.com", "empresa": "BCo", "consentimiento": "on"},
    )
    response = client.get("/admin/leads.csv", headers=_auth_header())
    assert response.status_code == 200
    assert "text/csv" in response.headers["content-type"]
    body = response.text
    # UTF-8 BOM so Excel respects accents
    assert body.startswith("")
    assert "b@b.com" in body
    assert "nombre,email,empresa" in body


def test_admin_stats_returns_counts(client):
    client.post(
        "/register",
        data={"nombre": "C", "email": "c@c.com", "empresa": "CCo", "consentimiento": "on"},
    )
    response = client.get("/admin/stats", headers=_auth_header())
    assert response.status_code == 200
    body = response.json()
    assert "total_leads" in body
    assert "total_downloads" in body
    assert "downloads_por_archivo" in body
    assert "top_5_empresas" in body
    assert body["total_leads"] >= 1
  • Step 2: Run tests — expect 404
pytest tests/test_admin.py -v

Expected: all fail (no /admin routes yet).

  • Step 3: Implement app/admin.py
import csv
import io

from fastapi import APIRouter, Depends, Query
from fastapi.responses import Response

from app.db import list_leads, stats
from app.security import require_admin

router = APIRouter(prefix="/admin", tags=["admin"])


@router.get("/leads.json")
def admin_leads_json(
    _user: str = Depends(require_admin),
    limit: int = Query(100, ge=1, le=1000),
    offset: int = Query(0, ge=0),
):
    return list_leads(limit=limit, offset=offset)


@router.get("/leads.csv")
def admin_leads_csv(_user: str = Depends(require_admin)):
    rows = list_leads(limit=10_000, offset=0)
    buf = io.StringIO()
    if rows:
        writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()))
        writer.writeheader()
        writer.writerows(rows)
    else:
        buf.write("(no leads)\n")
    # UTF-8 BOM so Excel respects accents
    payload = "" + buf.getvalue()
    return Response(
        content=payload,
        media_type="text/csv; charset=utf-8",
        headers={"Content-Disposition": 'attachment; filename="leads.csv"'},
    )


@router.get("/stats")
def admin_stats(_user: str = Depends(require_admin)):
    return stats()
  • Step 4: Include the router in app/main.py

Add to imports:

from app import admin

After the existing app.include_router(reports_api.router):

app.include_router(admin.router)
  • Step 5: Run tests — expect pass
pytest tests/test_admin.py -v

Expected: 5 passed.

  • Step 6: Commit
git add app/admin.py app/main.py tests/test_admin.py
git commit -m "feat(admin): leads.json, leads.csv, stats behind basic auth"

Task 13: Dockerfile, README, gitkeeps

Files:

  • Create: Dockerfile

  • Create: README.md

  • Create: material/.gitkeep

  • Step 1: Write Dockerfile

FROM python:3.11-slim

WORKDIR /app

# System deps for pandas / plotly (minimal)
RUN apt-get update && apt-get install -y --no-install-recommends \
    && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app/ ./app/
COPY static/ ./static/

# Runtime dirs — overlaid by Coolify persistent volumes in prod
RUN mkdir -p app/data/reports_output material

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
  • Step 2: Write README.md
# taller-wox.fitlabs.dev

Portal + API para el "Bootcamp Agentic AI con watsonx Orchestrate" de FactorIT.

- **Landing público:** `/`
- **Registro + descargas:** `/register`, `/descargas?token=…`, `/download/{file}?token=…`
- **API del taller** (consumida por agentes de watsonx Orchestrate):
  - `POST /api/historical-procedures`
  - `POST /api/available-procedures`
  - `GET  /api/member-insights`
  - `GET  /api/schedule`
  - `POST /api/reports/generate-report`
- **Admin** (HTTP Basic): `/admin/leads.json`, `/admin/leads.csv`, `/admin/stats`

## Local dev

```bash
python3.11 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
# edit .env if needed
uvicorn app.main:app --reload --port 8000

Visit http://localhost:8000/.

Run tests:

pytest -v

Deploy en Coolify

  1. Crear nueva aplicación → tipo "Dockerfile" → conectar al repo.
  2. Apuntar taller-wox.fitlabs.dev al servidor desde la UI; HTTPS automático con Let's Encrypt.
  3. Declarar volúmenes persistentes (UI → Storage):
    • /app/leads.db (bind file)
    • /app/app/data/reports_output (bind dir)
    • /app/material (bind dir)
  4. Configurar variables de entorno (UI → Environment):
    • SECRET_KEY (string aleatorio largo, ej. python -c "import secrets; print(secrets.token_urlsafe(48))")
    • ADMIN_USER, ADMIN_PASS
    • BASE_URL=https://taller-wox.fitlabs.dev
    • TOKEN_EXPIRY_HOURS=24
  5. Subir los 2 ZIPs (taller-wox-tecnico.zip, taller-wox-funcional.zip) al volumen /app/material vía SFTP o el file manager de Coolify. No requiere redeploy — los archivos se sirven directo del volumen.
  6. Deploy → Coolify buildea la imagen y arranca el container.

Workflow de actualizaciones

  • Códigogit push → Coolify rebuildea + redeploy.
  • ZIPs descargables → subir directo al volumen /app/material/ → sin redeploy.
  • Copy de las cards de descarga → editar app/frontend.py (lista DOWNLOADS) → push + redeploy.

Spec y diseño

  • Spec original: SPEC_taller_wox_fitlabs.md
  • Diseño validado (autoridad): docs/superpowers/specs/2026-05-12-taller-wox-design.md
  • Plan de implementación: docs/superpowers/plans/2026-05-12-taller-wox.md

Estructura

app/
  main.py            FastAPI bootstrap
  config.py          env vars
  db.py              SQLite
  security.py        tokens + basic auth
  frontend.py        / · /register · /descargas · /download
  benefits_api.py    endpoints 1-4
  reports_api.py     endpoint 5
  admin.py           /admin/*
  data/              datasets de los endpoints
  templates/         jinja2
static/              CSS, JS, imágenes
material/            ZIPs descargables (volumen persistente en prod)
tests/               pytest
Dockerfile

- [ ] **Step 3: Create gitkeep for material/**

```bash
mkdir -p material && touch material/.gitkeep
  • Step 4: Test that docker build works
docker build -t taller-wox .

Expected: clean build, no errors. (If you don't have Docker locally, skip this step — Coolify will catch any build issues.)

  • Step 5: Smoke test in Docker locally (optional)
docker run --rm -p 8000:8000 \
  -e SECRET_KEY=local-dev-key \
  -e ADMIN_USER=admin \
  -e ADMIN_PASS=admin \
  -e BASE_URL=http://localhost:8000 \
  taller-wox

Visit http://localhost:8000/health — should return ok. Stop with Ctrl+C.

  • Step 6: Commit
git add Dockerfile README.md material/
git commit -m "feat(deploy): Dockerfile, README with Coolify instructions, material/ gitkeep"

Task 14: End-to-end smoke check against SPEC §8

Before declaring done, run through the acceptance criteria from the design doc §9. This is a manual checklist task, not code.

  • Step 1: Full test suite green
pytest -v

Expected: all tests pass.

  • Step 2: Local server up
uvicorn app.main:app --reload --port 8000
  • Step 3: Frontend smoke (curl + browser)
curl -s http://localhost:8000/ | head -30

Expected: HTML with "Bootcamp Agentic AI".

curl -s -X POST http://localhost:8000/register \
  -d "nombre=Test&email=test@test.com&empresa=ACME&consentimiento=on" \
  -i | head -10

Expected: HTTP/1.1 303 See Other and location: /descargas?token=....

In a browser, open http://localhost:8000/ and complete the form. Verify redirect to /descargas with personalized greeting and 2 cards.

  • Step 4: API smoke
curl -s -X POST http://localhost:8000/api/historical-procedures \
  -H "Content-Type: application/json" \
  -d '{"filters":"[]","group_by":"[]"}' | python3 -m json.tool | head -20

curl -s -X POST http://localhost:8000/api/historical-procedures \
  -H "Content-Type: application/json" \
  -d '{"filters":"[{\"column\":\"member_name\",\"operator\":\"equals\",\"value\":\"Charlie Smith\"}]","group_by":"[]"}' | python3 -c "import json,sys; d=json.load(sys.stdin); print('Charlie rows:', len(d['result']))"

curl -s http://localhost:8000/api/member-insights | python3 -c "import json,sys; d=json.load(sys.stdin); print('overdue:', len(d['result']['overdue_procedures']))"

curl -s http://localhost:8000/api/schedule | python3 -m json.tool

curl -s -X POST http://localhost:8000/api/reports/generate-report \
  -H "Content-Type: application/json" \
  -d '{"layout_config":"[\"care_report\"]"}' | python3 -m json.tool

Expected:

  • historical_procedures: 50 records, then 5 Charlie rows

  • member_insights: 4 overdue procedures

  • schedule: text starting "Para agendar una cita médica"

  • generate-report: public_url ending in .html. Open the URL in browser → verify chart + table render.

  • Step 5: Admin smoke

curl -s -u admin:admin http://localhost:8000/admin/stats | python3 -m json.tool
curl -s -u admin:admin http://localhost:8000/admin/leads.csv | head -3

Expected: stats JSON with counts, CSV with BOM + headers + the test lead from step 3.

  • Step 6: Stop server

Ctrl+C.

  • Step 7: Push to remote (Coolify auto-deploys)
git push origin master

Coolify watches the repo and rebuilds automatically. Verify in the Coolify UI that the deploy succeeded.

  • Step 8: Production smoke

After Coolify reports the deploy is live:

curl -sI https://taller-wox.fitlabs.dev/health
# Expect 200 + valid HTTPS (no -k flag needed)

curl -s https://taller-wox.fitlabs.dev/api/member-insights | python3 -m json.tool | head -10
# Expect the member object
  • Step 9: Upload the 2 ZIPs to the volume

Felipe: upload taller-wox-tecnico.zip and taller-wox-funcional.zip to the /app/material/ volume via Coolify's file manager or SFTP.

Verify in browser at https://taller-wox.fitlabs.dev/ → register → confirm the 2 download cards now show real file sizes and downloads work.

  • Step 10: Watsonx end-to-end (the real test)

Import openapi-tools-spec.json in a fresh watsonx Orchestrate agent. Run these prompts:

  • "¿Estoy atrasado en algún chequeo?" → must call /api/member-insights, return the 4 overdue procedures.
  • "¿Cuánto cuesta una resonancia magnética?" → must call /api/available-procedures with a contains filter on procedure, return MRI results.
  • "Create a care report" (with openapi-tools-report.json imported) → must call /api/reports/generate-report and return a URL the agent shows back.

If all three work, you are done.

  • Step 11: Final commit (notes from smoke testing, if any)

If you discovered any issues during smoke testing and fixed them, commit those fixes here. Otherwise, no commit needed.


Self-review (run after writing the plan)

Confirmed during writing of this plan:

1. Spec coverage:

  • Design §2 Architecture → Tasks 1, 11 (main.py wiring) ✓
  • Design §3.1 Landing visual → Task 10 (templates) ✓
  • Design §3.2 Form with honeypot + duplicates → Tasks 4, 11 ✓
  • Design §3.3 Descargas with 2 cards → Tasks 10, 11 ✓
  • Design §3.4 Download with whitelist → Task 11 ✓
  • Design §3.5 The 5 API endpoints → Tasks 6, 7, 8, 9 ✓
  • Design §3.6 Admin → Task 12 ✓
  • Design §4.1 SQLite schema with UNIQUE email + times_registered → Task 3 ✓
  • Design §4.2 Persistent volumes → Task 13 README ✓
  • Design §5 Dockerfile + Coolify config → Task 13 ✓
  • Design §6 Synthetic datasets → Task 5 ✓
  • Design §9 Acceptance criteria → Task 14 ✓

2. Placeholder scan: No TBDs, no "implement later", no vague "handle edge cases" without specifics. All code blocks are complete and runnable. ✓

3. Type consistency: upsert_lead, get_lead_by_email, log_download, list_leads, stats are referenced consistently across tasks 3, 11, 12. create_download_token/verify_download_token referenced consistently across tasks 4 and 11. require_admin referenced consistently across tasks 4 and 12. ✓


Execution Handoff

Plan complete and saved to docs/superpowers/plans/2026-05-12-taller-wox.md. Two execution options:

1. Subagent-Driven (recommended) — I dispatch a fresh subagent per task, review between tasks, fast iteration. Good for 48h timeline because tasks run in parallel where possible and you get a clean review checkpoint between each.

2. Inline Execution — Execute tasks in this session using superpowers:executing-plans, batch execution with checkpoints for review. More predictable, single context.

Which approach?