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

FACTORIT · FIT

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

{title}

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

{line}

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

{title}

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

{title}

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

FACTORIT · FIT

Bootcamp Agentic AI con watsonx Orchestrate

Construye tu primer agente de IA en 4 horas.

Acceder al material →

¿Qué vas a construir?

⚙️

Tu primer agente

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

📚

Multi-agente con RAG

Compón agentes especializados con base de conocimiento documental.

📊

Reportes y APIs

Genera reportes ejecutivos invocando endpoints en vivo.

El taller en números

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

Descarga todo el material

Registra tus datos para acceder al kit completo del bootcamp.

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

¡Hola {{ nombre }}!

Acá tienes todo el material del bootcamp.

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

{{ d.title }}

{{ d.description }}

{{ d.size_mb }} MB

{% if d.available %} Descargar {% else %} No disponible {% endif %}
{% endfor %}
{% endblock %} ``` - [ ] **Step 5: Write `static/css/styles.css`** ```css :root { --navy: #0A1F44; --blue: #1E4FA8; --cyan: #00B5D8; --orange: #FF7A00; --orange-hover: #E66A00; --cream: #F5F7FA; --text: #1A1A1A; --muted: #5A6473; --border: #D8DEE5; } * { box-sizing: border-box; } html { scroll-behavior: smooth; } body { margin: 0; font-family: 'Inter', system-ui, sans-serif; color: var(--text); background: white; line-height: 1.6; } .site-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 32px; background: white; border-bottom: 1px solid var(--border); } .site-header .brand img { display: block; } .powered-by { color: var(--muted); font-size: 13px; } .powered-by strong { color: var(--navy); } .hero { background: linear-gradient(135deg, var(--navy) 0%, var(--blue) 100%); color: white; padding: 80px 32px; min-height: 80vh; display: flex; align-items: center; } .hero-inner { max-width: 800px; margin: 0 auto; text-align: center; } .eyebrow { color: var(--cyan); font-weight: 700; letter-spacing: 2px; margin: 0 0 16px; } .hero h1 { font-size: clamp(32px, 5vw, 56px); margin: 0 0 16px; font-weight: 800; line-height: 1.15; } .subtitle { font-size: 20px; color: var(--cream); font-style: italic; margin: 0 0 32px; } .btn { display: inline-block; padding: 14px 28px; border-radius: 8px; font-weight: 700; text-decoration: none; cursor: pointer; border: 0; font-size: 16px; transition: transform 0.15s ease, background 0.15s ease; } .btn-primary { background: var(--orange); color: white; } .btn-primary:hover { background: var(--orange-hover); transform: translateY(-1px); } .btn-large { padding: 18px 36px; font-size: 18px; } .btn-disabled { background: var(--border); color: var(--muted); cursor: not-allowed; } .section-title { text-align: center; font-size: 32px; color: var(--navy); margin: 0 0 32px; } .cards-section, .stats-section, .form-section, .descargas-section { padding: 64px 32px; max-width: 1100px; margin: 0 auto; } .cards-section { background: var(--cream); max-width: none; } .cards-section .section-title, .cards-section .cards { max-width: 1100px; margin-left: auto; margin-right: auto; } .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 24px; } .card { background: white; padding: 32px; border-radius: 12px; box-shadow: 0 2px 8px rgba(10, 31, 68, 0.08); transition: transform 0.2s ease, box-shadow 0.2s ease; } .card:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(10, 31, 68, 0.14); } .card .icon { font-size: 36px; margin-bottom: 12px; } .card h3 { color: var(--navy); margin: 0 0 8px; } .card p { color: var(--muted); margin: 0; } .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 24px; text-align: center; } .stat { padding: 24px; } .stat .num { display: block; font-size: 56px; font-weight: 800; color: var(--orange); line-height: 1; } .stat .label { display: block; color: var(--muted); margin-top: 8px; font-size: 14px; text-transform: uppercase; letter-spacing: 1px; } .form-section { background: white; max-width: 600px; } .form-intro { text-align: center; color: var(--muted); margin: 0 0 32px; } .register-form { display: flex; flex-direction: column; gap: 20px; } .field label { display: block; font-weight: 600; margin-bottom: 6px; color: var(--navy); } .field input[type="text"], .field input[type="email"] { width: 100%; padding: 12px 14px; border: 2px solid var(--border); border-radius: 8px; font-size: 16px; font-family: inherit; } .field input:focus { outline: none; border-color: var(--cyan); } .field-checkbox { display: flex; gap: 12px; align-items: flex-start; } .field-checkbox input { margin-top: 4px; } .field-checkbox label { font-weight: 400; color: var(--muted); font-size: 14px; } .hp-field { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden; } .banner { padding: 16px 32px; text-align: center; } .banner-error { background: #FFF1F0; color: #B0211A; border-bottom: 2px solid #B0211A; } .descargas-section { text-align: center; } .hello { color: var(--navy); font-size: 40px; margin: 0 0 8px; } .hello-sub { color: var(--muted); margin: 0 0 48px; } .download-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 32px; } .download-card { background: var(--cream); padding: 40px; border-radius: 16px; border: 2px solid transparent; transition: border-color 0.2s ease, transform 0.2s ease; } .download-card:hover { border-color: var(--cyan); transform: translateY(-2px); } .download-card .dl-icon { font-size: 56px; margin-bottom: 16px; } .download-card h2 { color: var(--navy); margin: 0 0 8px; font-size: 24px; } .download-card p { color: var(--muted); margin: 0 0 8px; } .download-card .size { font-size: 13px; color: var(--muted); font-weight: 600; } .site-footer { background: var(--navy); color: white; padding: 32px; margin-top: 64px; } .footer-inner { max-width: 1100px; margin: 0 auto; text-align: center; } .footer-inner a { color: var(--cyan); text-decoration: none; } .copyright { color: rgba(255, 255, 255, 0.5); font-size: 13px; margin-top: 16px; } @media (max-width: 768px) { .hero { min-height: 70vh; padding: 48px 24px; } .cards-section, .stats-section, .form-section, .descargas-section { padding: 48px 24px; } .download-card { padding: 24px; } } ``` - [ ] **Step 6: Write `static/js/app.js` (minimal)** ```javascript // Smooth scroll for in-page anchors is already handled by html { scroll-behavior: smooth }. // Light enhancement: fade-in sections as they enter the viewport. document.addEventListener('DOMContentLoaded', () => { const sections = document.querySelectorAll('.cards-section, .stats-section, .form-section'); if (!('IntersectionObserver' in window)) return; const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { entry.target.style.opacity = '1'; entry.target.style.transform = 'translateY(0)'; observer.unobserve(entry.target); } }); }, { threshold: 0.1 }); sections.forEach(s => { s.style.opacity = '0'; s.style.transform = 'translateY(20px)'; s.style.transition = 'opacity 0.6s ease, transform 0.6s ease'; observer.observe(s); }); }); ``` - [ ] **Step 7: Create gitkeep for static/img** ```bash mkdir -p static/img && touch static/img/.gitkeep ``` - [ ] **Step 8: Commit (no tests yet — wired up in Task 11)** ```bash git add app/templates/ static/ git commit -m "feat(frontend): templates (base, index, descargas) + FIT-branded CSS + light JS" ``` --- ## Task 11: Frontend routes (landing, register, descargas, download) **Files:** - Create: `app/frontend.py` - Modify: `app/main.py` - Create: `tests/test_frontend.py` - [ ] **Step 1: Write failing tests** ```python # tests/test_frontend.py import os from pathlib import Path def test_landing_renders(client): response = client.get("/") assert response.status_code == 200 assert "text/html" in response.headers["content-type"] assert "Bootcamp Agentic AI" in response.text assert 'name="email"' in response.text assert 'name="website"' in response.text # honeypot present def test_register_valid_form_redirects_with_token(client): response = client.post( "/register", data={ "nombre": "Felipe Arentsen", "email": "felipe@factorit.com", "empresa": "FactorIT", "consentimiento": "on", }, follow_redirects=False, ) assert response.status_code == 303 location = response.headers["location"] assert location.startswith("/descargas?token=") def test_register_missing_consent_fails(client): response = client.post( "/register", data={ "nombre": "Felipe", "email": "f@x.com", "empresa": "X", }, follow_redirects=False, ) assert response.status_code == 422 or response.status_code == 400 def test_register_honeypot_filled_silently_drops(client): response = client.post( "/register", data={ "nombre": "Bot", "email": "bot@spam.com", "empresa": "Spam Inc", "consentimiento": "on", "website": "https://spam.com", }, follow_redirects=False, ) # Honeypot: return a believable redirect but DON'T persist assert response.status_code == 303 # Verify nothing was saved from app.db import get_lead_by_email assert get_lead_by_email("bot@spam.com") is None def test_register_duplicate_email_reissues_token(client): for _ in range(2): client.post( "/register", data={ "nombre": "Felipe", "email": "dup@factorit.com", "empresa": "FactorIT", "consentimiento": "on", }, follow_redirects=False, ) from app.db import get_lead_by_email lead = get_lead_by_email("dup@factorit.com") assert lead["times_registered"] == 2 def test_descargas_with_invalid_token_redirects_home(client): response = client.get("/descargas?token=invalid-junk", follow_redirects=False) assert response.status_code == 307 assert response.headers["location"].startswith("/?error=") def test_descargas_with_valid_token_renders(client): reg = client.post( "/register", data={ "nombre": "Maria", "email": "maria@test.com", "empresa": "Test Co", "consentimiento": "on", }, follow_redirects=False, ) token = reg.headers["location"].split("token=")[1] response = client.get(f"/descargas?token={token}") assert response.status_code == 200 assert "¡Hola Maria!" in response.text assert "Material técnico" in response.text assert "Material funcional" in response.text def test_download_with_valid_token_serves_file(client, tmp_path): # Put a fake zip in the configured material dir material_dir = Path(os.environ["MATERIAL_DIR"]) material_dir.mkdir(parents=True, exist_ok=True) (material_dir / "taller-wox-tecnico.zip").write_bytes(b"PK\x03\x04 fake zip") reg = client.post( "/register", data={ "nombre": "Test", "email": "test-dl@test.com", "empresa": "TC", "consentimiento": "on", }, follow_redirects=False, ) token = reg.headers["location"].split("token=")[1] response = client.get(f"/download/taller-wox-tecnico.zip?token={token}") assert response.status_code == 200 assert response.content == b"PK\x03\x04 fake zip" assert "attachment" in response.headers["content-disposition"] def test_download_with_invalid_filename_returns_404(client): reg = client.post( "/register", data={ "nombre": "Test", "email": "bad-fn@test.com", "empresa": "TC", "consentimiento": "on", }, follow_redirects=False, ) token = reg.headers["location"].split("token=")[1] response = client.get(f"/download/../../etc/passwd?token={token}") assert response.status_code == 404 ``` - [ ] **Step 2: Run tests — expect 404s on all routes** ```bash pytest tests/test_frontend.py -v ``` Expected: failures because routes don't exist yet. - [ ] **Step 3: Implement `app/frontend.py`** ```python from pathlib import Path from fastapi import APIRouter, Form, HTTPException, Request from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from itsdangerous import BadSignature, SignatureExpired from pydantic import EmailStr from app.config import get_settings from app.db import init_db, upsert_lead, log_download from app.security import ( create_download_token, verify_download_token, is_honeypot_filled, ) router = APIRouter() _TEMPLATES_DIR = Path(__file__).parent / "templates" templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) # Whitelist of downloadable filenames — guards against path traversal DOWNLOADS = [ { "filename": "taller-wox-tecnico.zip", "icon": "🧩", "title": "Material técnico", "description": "Specs OpenAPI, configs y artefactos para importar a watsonx Orchestrate.", }, { "filename": "taller-wox-funcional.zip", "icon": "📚", "title": "Material funcional", "description": "Manual paso a paso del bootcamp y deck de slides.", }, ] ALLOWED_FILENAMES = {d["filename"] for d in DOWNLOADS} # Ensure DB exists at startup (called from main.py) def ensure_db_initialized() -> None: init_db() @router.get("/", response_class=HTMLResponse) def landing(request: Request, error: str | None = None): error_msg = None if error == "token-invalido": error_msg = "El link expiró o es inválido. Regístrate de nuevo para descargar el material." return templates.TemplateResponse( "index.html", {"request": request, "error": error_msg}, ) @router.post("/register") def register( request: Request, nombre: str = Form(..., min_length=2, max_length=80), email: EmailStr = Form(...), empresa: str = Form(..., min_length=2, max_length=100), consentimiento: str = Form(...), # presence required website: str | None = Form(default=""), # honeypot ): if consentimiento != "on": raise HTTPException(status_code=400, detail="Consentimiento requerido") # Honeypot filled → fake-success without persisting if is_honeypot_filled(website): fake_token = create_download_token(email="honeypot@discarded.local", nombre="x") return RedirectResponse(url=f"/descargas?token={fake_token}", status_code=303) client_ip = request.client.host if request.client else None user_agent = request.headers.get("user-agent") upsert_lead( nombre=nombre, email=str(email), empresa=empresa, ip=client_ip, user_agent=user_agent, consent=True, ) token = create_download_token(email=str(email), nombre=nombre) return RedirectResponse(url=f"/descargas?token={token}", status_code=303) @router.get("/descargas", response_class=HTMLResponse) def descargas(request: Request, token: str | None = None): if not token: return RedirectResponse(url="/?error=token-invalido", status_code=307) try: data = verify_download_token(token) except (SignatureExpired, BadSignature): return RedirectResponse(url="/?error=token-invalido", status_code=307) settings = get_settings() material_dir = Path(settings.material_dir) downloads_view = [] for d in DOWNLOADS: path = material_dir / d["filename"] downloads_view.append({ **d, "available": path.exists(), "size_mb": round(path.stat().st_size / (1024 * 1024), 1) if path.exists() else 0, }) return templates.TemplateResponse( "descargas.html", { "request": request, "nombre": data.get("nombre", "amig@"), "token": token, "downloads": downloads_view, }, ) @router.get("/download/{filename}") def download(request: Request, filename: str, token: str | None = None): if not token: raise HTTPException(status_code=401, detail="Missing token") try: data = verify_download_token(token) except (SignatureExpired, BadSignature): raise HTTPException(status_code=401, detail="Invalid or expired token") if filename not in ALLOWED_FILENAMES: raise HTTPException(status_code=404, detail="File not found") path = Path(get_settings().material_dir) / filename if not path.exists(): raise HTTPException(status_code=404, detail="File not available yet") client_ip = request.client.host if request.client else None log_download(lead_email=data["email"], filename=filename, ip=client_ip) return FileResponse( path=str(path), filename=filename, media_type="application/zip", ) ``` - [ ] **Step 4: Wire static mount + frontend router + DB init into `app/main.py`** Replace `app/main.py` with: ```python from pathlib import Path from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from app import benefits_api, frontend, reports_api from app.config import get_settings from app.db import init_db settings = get_settings() app = FastAPI(title="taller-wox.fitlabs.dev", version="1.0.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=False, allow_methods=["GET", "POST"], allow_headers=["*"], ) # Static assets (CSS, JS, images) _STATIC_DIR = Path(__file__).parent.parent / "static" app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") # Routers app.include_router(frontend.router) app.include_router(benefits_api.router) app.include_router(reports_api.router) # Generated reports served as static _reports_dir = Path(settings.reports_output_dir) _reports_dir.mkdir(parents=True, exist_ok=True) app.mount( "/api/reports/output", StaticFiles(directory=str(_reports_dir)), name="reports_output", ) @app.on_event("startup") def on_startup() -> None: init_db() @app.get("/health") def health(): return {"status": "ok", "base_url": settings.base_url} ``` - [ ] **Step 5: Run tests — expect pass** ```bash pytest tests/test_frontend.py -v ``` Expected: `9 passed`. - [ ] **Step 6: Full test suite check** ```bash pytest -v ``` Expected: all tests pass. - [ ] **Step 7: Manual smoke (browser)** ```bash uvicorn app.main:app --reload --port 8000 ``` Open `http://localhost:8000/`. Verify: - Landing renders with FIT branding (navy hero, orange CTA) - Form has all 4 fields + invisible honeypot - Submit redirects to `/descargas?token=...` - Descargas page shows 2 cards (both marked "No disponible" since material/ is empty in dev) - Hitting `/descargas?token=garbage` redirects home with banner Stop the server. - [ ] **Step 8: Commit** ```bash git add app/frontend.py app/main.py tests/test_frontend.py git commit -m "feat(frontend): landing, registration with honeypot, descargas with token, gated download" ``` --- ## Task 12: Admin endpoints **Files:** - Create: `app/admin.py` - Modify: `app/main.py` - Create: `tests/test_admin.py` - [ ] **Step 1: Write failing tests** ```python # tests/test_admin.py import base64 def _auth_header(user: str = "testadmin", password: str = "testpass") -> dict: token = base64.b64encode(f"{user}:{password}".encode()).decode() return {"Authorization": f"Basic {token}"} def test_admin_leads_requires_auth(client): response = client.get("/admin/leads.json") assert response.status_code == 401 def test_admin_leads_with_bad_credentials_rejected(client): response = client.get("/admin/leads.json", headers=_auth_header(password="wrong")) assert response.status_code == 401 def test_admin_leads_json_returns_list(client): client.post( "/register", data={"nombre": "A", "email": "a@a.com", "empresa": "ACo", "consentimiento": "on"}, ) response = client.get("/admin/leads.json", headers=_auth_header()) assert response.status_code == 200 body = response.json() assert isinstance(body, list) assert any(l["email"] == "a@a.com" for l in body) def test_admin_leads_csv_returns_csv(client): client.post( "/register", data={"nombre": "B", "email": "b@b.com", "empresa": "BCo", "consentimiento": "on"}, ) response = client.get("/admin/leads.csv", headers=_auth_header()) assert response.status_code == 200 assert "text/csv" in response.headers["content-type"] body = response.text # UTF-8 BOM so Excel respects accents assert body.startswith("") assert "b@b.com" in body assert "nombre,email,empresa" in body def test_admin_stats_returns_counts(client): client.post( "/register", data={"nombre": "C", "email": "c@c.com", "empresa": "CCo", "consentimiento": "on"}, ) response = client.get("/admin/stats", headers=_auth_header()) assert response.status_code == 200 body = response.json() assert "total_leads" in body assert "total_downloads" in body assert "downloads_por_archivo" in body assert "top_5_empresas" in body assert body["total_leads"] >= 1 ``` - [ ] **Step 2: Run tests — expect 404** ```bash pytest tests/test_admin.py -v ``` Expected: all fail (no /admin routes yet). - [ ] **Step 3: Implement `app/admin.py`** ```python import csv import io from fastapi import APIRouter, Depends, Query from fastapi.responses import Response from app.db import list_leads, stats from app.security import require_admin router = APIRouter(prefix="/admin", tags=["admin"]) @router.get("/leads.json") def admin_leads_json( _user: str = Depends(require_admin), limit: int = Query(100, ge=1, le=1000), offset: int = Query(0, ge=0), ): return list_leads(limit=limit, offset=offset) @router.get("/leads.csv") def admin_leads_csv(_user: str = Depends(require_admin)): rows = list_leads(limit=10_000, offset=0) buf = io.StringIO() if rows: writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys())) writer.writeheader() writer.writerows(rows) else: buf.write("(no leads)\n") # UTF-8 BOM so Excel respects accents payload = "" + buf.getvalue() return Response( content=payload, media_type="text/csv; charset=utf-8", headers={"Content-Disposition": 'attachment; filename="leads.csv"'}, ) @router.get("/stats") def admin_stats(_user: str = Depends(require_admin)): return stats() ``` - [ ] **Step 4: Include the router in `app/main.py`** Add to imports: ```python from app import admin ``` After the existing `app.include_router(reports_api.router)`: ```python app.include_router(admin.router) ``` - [ ] **Step 5: Run tests — expect pass** ```bash pytest tests/test_admin.py -v ``` Expected: `5 passed`. - [ ] **Step 6: Commit** ```bash git add app/admin.py app/main.py tests/test_admin.py git commit -m "feat(admin): leads.json, leads.csv, stats behind basic auth" ``` --- ## Task 13: Dockerfile, README, gitkeeps **Files:** - Create: `Dockerfile` - Create: `README.md` - Create: `material/.gitkeep` - [ ] **Step 1: Write `Dockerfile`** ```dockerfile FROM python:3.11-slim WORKDIR /app # System deps for pandas / plotly (minimal) RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app/ ./app/ COPY static/ ./static/ # Runtime dirs — overlaid by Coolify persistent volumes in prod RUN mkdir -p app/data/reports_output material EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] ``` - [ ] **Step 2: Write `README.md`** ```markdown # taller-wox.fitlabs.dev Portal + API para el "Bootcamp Agentic AI con watsonx Orchestrate" de FactorIT. - **Landing público:** `/` - **Registro + descargas:** `/register`, `/descargas?token=…`, `/download/{file}?token=…` - **API del taller** (consumida por agentes de watsonx Orchestrate): - `POST /api/historical-procedures` - `POST /api/available-procedures` - `GET /api/member-insights` - `GET /api/schedule` - `POST /api/reports/generate-report` - **Admin** (HTTP Basic): `/admin/leads.json`, `/admin/leads.csv`, `/admin/stats` ## Local dev ```bash python3.11 -m venv .venv source .venv/bin/activate pip install -r requirements.txt cp .env.example .env # edit .env if needed uvicorn app.main:app --reload --port 8000 ``` Visit `http://localhost:8000/`. Run tests: ```bash pytest -v ``` ## Deploy en Coolify 1. Crear nueva aplicación → tipo "Dockerfile" → conectar al repo. 2. Apuntar `taller-wox.fitlabs.dev` al servidor desde la UI; HTTPS automático con Let's Encrypt. 3. **Declarar volúmenes persistentes** (UI → Storage): - `/app/leads.db` (bind file) - `/app/app/data/reports_output` (bind dir) - `/app/material` (bind dir) 4. **Configurar variables de entorno** (UI → Environment): - `SECRET_KEY` (string aleatorio largo, ej. `python -c "import secrets; print(secrets.token_urlsafe(48))"`) - `ADMIN_USER`, `ADMIN_PASS` - `BASE_URL=https://taller-wox.fitlabs.dev` - `TOKEN_EXPIRY_HOURS=24` 5. Subir los 2 ZIPs (`taller-wox-tecnico.zip`, `taller-wox-funcional.zip`) al volumen `/app/material` vía SFTP o el file manager de Coolify. **No requiere redeploy** — los archivos se sirven directo del volumen. 6. Deploy → Coolify buildea la imagen y arranca el container. ### Workflow de actualizaciones - **Código** → `git push` → Coolify rebuildea + redeploy. - **ZIPs descargables** → subir directo al volumen `/app/material/` → sin redeploy. - **Copy de las cards de descarga** → editar `app/frontend.py` (lista `DOWNLOADS`) → push + redeploy. ## Spec y diseño - Spec original: `SPEC_taller_wox_fitlabs.md` - Diseño validado (autoridad): `docs/superpowers/specs/2026-05-12-taller-wox-design.md` - Plan de implementación: `docs/superpowers/plans/2026-05-12-taller-wox.md` ## Estructura ``` app/ main.py FastAPI bootstrap config.py env vars db.py SQLite security.py tokens + basic auth frontend.py / · /register · /descargas · /download benefits_api.py endpoints 1-4 reports_api.py endpoint 5 admin.py /admin/* data/ datasets de los endpoints templates/ jinja2 static/ CSS, JS, imágenes material/ ZIPs descargables (volumen persistente en prod) tests/ pytest Dockerfile ``` ``` - [ ] **Step 3: Create gitkeep for material/** ```bash mkdir -p material && touch material/.gitkeep ``` - [ ] **Step 4: Test that `docker build` works** ```bash docker build -t taller-wox . ``` Expected: clean build, no errors. (If you don't have Docker locally, skip this step — Coolify will catch any build issues.) - [ ] **Step 5: Smoke test in Docker locally (optional)** ```bash docker run --rm -p 8000:8000 \ -e SECRET_KEY=local-dev-key \ -e ADMIN_USER=admin \ -e ADMIN_PASS=admin \ -e BASE_URL=http://localhost:8000 \ taller-wox ``` Visit `http://localhost:8000/health` — should return ok. Stop with Ctrl+C. - [ ] **Step 6: Commit** ```bash git add Dockerfile README.md material/ git commit -m "feat(deploy): Dockerfile, README with Coolify instructions, material/ gitkeep" ``` --- ## Task 14: End-to-end smoke check against SPEC §8 Before declaring done, run through the acceptance criteria from the design doc §9. This is a manual checklist task, not code. - [ ] **Step 1: Full test suite green** ```bash pytest -v ``` Expected: all tests pass. - [ ] **Step 2: Local server up** ```bash uvicorn app.main:app --reload --port 8000 ``` - [ ] **Step 3: Frontend smoke (curl + browser)** ```bash curl -s http://localhost:8000/ | head -30 ``` Expected: HTML with "Bootcamp Agentic AI". ```bash curl -s -X POST http://localhost:8000/register \ -d "nombre=Test&email=test@test.com&empresa=ACME&consentimiento=on" \ -i | head -10 ``` Expected: `HTTP/1.1 303 See Other` and `location: /descargas?token=...`. In a browser, open `http://localhost:8000/` and complete the form. Verify redirect to `/descargas` with personalized greeting and 2 cards. - [ ] **Step 4: API smoke** ```bash curl -s -X POST http://localhost:8000/api/historical-procedures \ -H "Content-Type: application/json" \ -d '{"filters":"[]","group_by":"[]"}' | python3 -m json.tool | head -20 curl -s -X POST http://localhost:8000/api/historical-procedures \ -H "Content-Type: application/json" \ -d '{"filters":"[{\"column\":\"member_name\",\"operator\":\"equals\",\"value\":\"Charlie Smith\"}]","group_by":"[]"}' | python3 -c "import json,sys; d=json.load(sys.stdin); print('Charlie rows:', len(d['result']))" curl -s http://localhost:8000/api/member-insights | python3 -c "import json,sys; d=json.load(sys.stdin); print('overdue:', len(d['result']['overdue_procedures']))" curl -s http://localhost:8000/api/schedule | python3 -m json.tool curl -s -X POST http://localhost:8000/api/reports/generate-report \ -H "Content-Type: application/json" \ -d '{"layout_config":"[\"care_report\"]"}' | python3 -m json.tool ``` Expected: - historical_procedures: 50 records, then 5 Charlie rows - member_insights: 4 overdue procedures - schedule: text starting "Para agendar una cita médica" - generate-report: `public_url` ending in `.html`. Open the URL in browser → verify chart + table render. - [ ] **Step 5: Admin smoke** ```bash curl -s -u admin:admin http://localhost:8000/admin/stats | python3 -m json.tool curl -s -u admin:admin http://localhost:8000/admin/leads.csv | head -3 ``` Expected: stats JSON with counts, CSV with BOM + headers + the test lead from step 3. - [ ] **Step 6: Stop server** Ctrl+C. - [ ] **Step 7: Push to remote (Coolify auto-deploys)** ```bash git push origin master ``` Coolify watches the repo and rebuilds automatically. Verify in the Coolify UI that the deploy succeeded. - [ ] **Step 8: Production smoke** After Coolify reports the deploy is live: ```bash curl -sI https://taller-wox.fitlabs.dev/health # Expect 200 + valid HTTPS (no -k flag needed) curl -s https://taller-wox.fitlabs.dev/api/member-insights | python3 -m json.tool | head -10 # Expect the member object ``` - [ ] **Step 9: Upload the 2 ZIPs to the volume** Felipe: upload `taller-wox-tecnico.zip` and `taller-wox-funcional.zip` to the `/app/material/` volume via Coolify's file manager or SFTP. Verify in browser at `https://taller-wox.fitlabs.dev/` → register → confirm the 2 download cards now show real file sizes and downloads work. - [ ] **Step 10: Watsonx end-to-end (the real test)** Import `openapi-tools-spec.json` in a fresh watsonx Orchestrate agent. Run these prompts: - "¿Estoy atrasado en algún chequeo?" → must call `/api/member-insights`, return the 4 overdue procedures. - "¿Cuánto cuesta una resonancia magnética?" → must call `/api/available-procedures` with a contains filter on `procedure`, return MRI results. - "Create a care report" (with `openapi-tools-report.json` imported) → must call `/api/reports/generate-report` and return a URL the agent shows back. If all three work, you are done. - [ ] **Step 11: Final commit (notes from smoke testing, if any)** If you discovered any issues during smoke testing and fixed them, commit those fixes here. Otherwise, no commit needed. --- ## Self-review (run after writing the plan) Confirmed during writing of this plan: **1. Spec coverage:** - Design §2 Architecture → Tasks 1, 11 (main.py wiring) ✓ - Design §3.1 Landing visual → Task 10 (templates) ✓ - Design §3.2 Form with honeypot + duplicates → Tasks 4, 11 ✓ - Design §3.3 Descargas with 2 cards → Tasks 10, 11 ✓ - Design §3.4 Download with whitelist → Task 11 ✓ - Design §3.5 The 5 API endpoints → Tasks 6, 7, 8, 9 ✓ - Design §3.6 Admin → Task 12 ✓ - Design §4.1 SQLite schema with UNIQUE email + times_registered → Task 3 ✓ - Design §4.2 Persistent volumes → Task 13 README ✓ - Design §5 Dockerfile + Coolify config → Task 13 ✓ - Design §6 Synthetic datasets → Task 5 ✓ - Design §9 Acceptance criteria → Task 14 ✓ **2. Placeholder scan:** No TBDs, no "implement later", no vague "handle edge cases" without specifics. All code blocks are complete and runnable. ✓ **3. Type consistency:** `upsert_lead`, `get_lead_by_email`, `log_download`, `list_leads`, `stats` are referenced consistently across tasks 3, 11, 12. `create_download_token`/`verify_download_token` referenced consistently across tasks 4 and 11. `require_admin` referenced consistently across tasks 4 and 12. ✓ --- ## Execution Handoff **Plan complete and saved to `docs/superpowers/plans/2026-05-12-taller-wox.md`. Two execution options:** **1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. Good for 48h timeline because tasks run in parallel where possible and you get a clean review checkpoint between each. **2. Inline Execution** — Execute tasks in this session using `superpowers:executing-plans`, batch execution with checkpoints for review. More predictable, single context. **Which approach?**