feat: initial implementation taller-wox.fitlabs.dev
Portal FastAPI + 5 endpoints REST para Bootcamp Agentic AI con watsonx Orchestrate (FactorIT). Single container, Coolify-ready. - Landing brandeado FIT con formulario de registro (honeypot anti-bot) - Tokens itsdangerous para descargas (24h expiry) - 5 endpoints API: historical/available procedures, member-insights, schedule, generate-report (Jinja2 + Plotly) - SQLite con upsert-on-email para leads + log de descargas - Admin endpoints (HTTP Basic): leads.json, leads.csv, stats - 23 tests pytest pasando - Dockerfile listo para Coolify con volúmenes persistentes (/app/leads.db, /app/app/data/reports_output, /app/material) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
42
app/admin.py
Normal file
42
app/admin.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import csv
|
||||
import io
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import Response
|
||||
|
||||
from app.db import list_leads, stats
|
||||
from app.security import require_admin
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
@router.get("/leads.json")
|
||||
def admin_leads_json(
|
||||
_user: str = Depends(require_admin),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
return list_leads(limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/leads.csv")
|
||||
def admin_leads_csv(_user: str = Depends(require_admin)):
|
||||
rows = list_leads(limit=10_000, offset=0)
|
||||
buf = io.StringIO()
|
||||
if rows:
|
||||
writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()))
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
else:
|
||||
buf.write("(no leads)\n")
|
||||
payload = "" + buf.getvalue()
|
||||
return Response(
|
||||
content=payload,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": 'attachment; filename="leads.csv"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def admin_stats(_user: str = Depends(require_admin)):
|
||||
return stats()
|
||||
84
app/benefits_api.py
Normal file
84
app/benefits_api.py
Normal file
@@ -0,0 +1,84 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["benefits"])
|
||||
|
||||
_DATA_DIR = Path(__file__).parent / "data"
|
||||
|
||||
with (_DATA_DIR / "member_insights.json").open() as f:
|
||||
_MEMBER_INSIGHTS = json.load(f)
|
||||
|
||||
_SCHEDULE_TEXT = (_DATA_DIR / "schedule_response.txt").read_text(encoding="utf-8").strip()
|
||||
|
||||
_HISTORICAL = pd.read_csv(_DATA_DIR / "historical_procedures.csv")
|
||||
_AVAILABLE = pd.read_csv(_DATA_DIR / "available_procedures.csv")
|
||||
|
||||
|
||||
_OPS = {
|
||||
"equals": lambda s, v: s == v,
|
||||
"ne": lambda s, v: s != v,
|
||||
"contains": lambda s, v: s.astype(str).str.contains(str(v), case=False, na=False),
|
||||
"gt": lambda s, v: s > v,
|
||||
"lt": lambda s, v: s < v,
|
||||
"ge": lambda s, v: s >= v,
|
||||
"le": lambda s, v: s <= v,
|
||||
}
|
||||
|
||||
|
||||
def _apply_filters_and_group(df: pd.DataFrame, filters_raw, group_by_raw) -> list[dict]:
|
||||
try:
|
||||
filters = json.loads(filters_raw) if isinstance(filters_raw, str) else (filters_raw or [])
|
||||
group_by = json.loads(group_by_raw) if isinstance(group_by_raw, str) else (group_by_raw or [])
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid JSON in filters or group_by: {exc}")
|
||||
|
||||
result = df.copy()
|
||||
for f in filters:
|
||||
col, op, val = f.get("column"), f.get("operator"), f.get("value")
|
||||
if col not in result.columns:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown column: {col}")
|
||||
if op not in _OPS:
|
||||
raise HTTPException(status_code=400, detail=f"Unsupported operator: {op}")
|
||||
result = result[_OPS[op](result[col], val)]
|
||||
|
||||
if group_by:
|
||||
missing = [c for c in group_by if c not in result.columns]
|
||||
if missing:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown group_by columns: {missing}")
|
||||
numeric_cols = result.select_dtypes(include="number").columns.tolist()
|
||||
result = result.groupby(group_by)[numeric_cols].mean().reset_index()
|
||||
|
||||
return result.to_dict(orient="records")
|
||||
|
||||
|
||||
@router.get("/member-insights")
|
||||
def member_insights():
|
||||
return _MEMBER_INSIGHTS
|
||||
|
||||
|
||||
@router.get("/schedule")
|
||||
def schedule():
|
||||
return {"result": _SCHEDULE_TEXT}
|
||||
|
||||
|
||||
@router.post("/historical-procedures")
|
||||
def historical_procedures(payload: dict):
|
||||
rows = _apply_filters_and_group(
|
||||
_HISTORICAL,
|
||||
payload.get("filters", "[]"),
|
||||
payload.get("group_by", "[]"),
|
||||
)
|
||||
return {"result": rows}
|
||||
|
||||
|
||||
@router.post("/available-procedures")
|
||||
def available_procedures(payload: dict):
|
||||
rows = _apply_filters_and_group(
|
||||
_AVAILABLE,
|
||||
payload.get("filters", "[]"),
|
||||
payload.get("group_by", "[]"),
|
||||
)
|
||||
return {"result": rows}
|
||||
21
app/config.py
Normal file
21
app/config.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
secret_key: str
|
||||
admin_user: str
|
||||
admin_pass: str
|
||||
token_expiry_hours: int = 24
|
||||
base_url: str = "https://taller-wox.fitlabs.dev"
|
||||
db_path: str = "./leads.db"
|
||||
material_dir: str = "./material"
|
||||
reports_output_dir: str = "./app/data/reports_output"
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", case_sensitive=False, extra="ignore")
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
11
app/data/aetna_claim_review_summary.csv
Normal file
11
app/data/aetna_claim_review_summary.csv
Normal file
@@ -0,0 +1,11 @@
|
||||
Date,CPT_Code,Description,Charged_Amount,Allowed_Amount,Plan_Paid,Patient_Responsibility
|
||||
2024-04-15,74177,CT Abdomen/Pelvis w/contrast,1847.50,1200.00,960.00,240.00
|
||||
2024-03-22,99213,Office visit established patient,185.00,120.00,96.00,24.00
|
||||
2024-02-10,80061,Lipid panel,82.00,55.00,44.00,11.00
|
||||
2024-01-18,99395,Preventive visit adult,310.00,250.00,250.00,0.00
|
||||
2023-12-05,90686,Influenza vaccine,55.00,40.00,40.00,0.00
|
||||
2023-11-12,87880,Strep A direct test,42.00,28.00,22.40,5.60
|
||||
2023-10-08,71046,Chest X-ray 2 views,210.00,140.00,112.00,28.00
|
||||
2023-09-22,93000,Electrocardiogram complete,138.00,90.00,72.00,18.00
|
||||
2023-08-14,99214,Office visit established mod complexity,250.00,170.00,136.00,34.00
|
||||
2023-07-03,36415,Venipuncture,18.00,12.00,9.60,2.40
|
||||
|
8
app/data/aetna_email.txt
Normal file
8
app/data/aetna_email.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
Claim #AET-2024-9912 has been processed.
|
||||
Service date: 2024-04-15
|
||||
Provider: City Hospital
|
||||
Total billed: $1,847.50
|
||||
Plan allowance: $1,200.00
|
||||
Plan paid: $960.00
|
||||
Patient responsibility (20% coinsurance after deductible): $240.00
|
||||
EOB available at member portal.
|
||||
29
app/data/available_procedures.csv
Normal file
29
app/data/available_procedures.csv
Normal file
@@ -0,0 +1,29 @@
|
||||
procedure,location,facility_rating,distance_miles,gold_ppo_plan_accepted,silver_epo_plan_accepted,accepted_plans,cost_facility,cost_physician,cost_anesthesia,cost_medication,total_cost
|
||||
MRI,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",1450.00,820.00,0.00,12.00,2282.00
|
||||
MRI,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",1380.00,790.00,0.00,11.00,2181.00
|
||||
MRI,Green Valley Clinic,4.5,8.4,true,false,"Gold PPO, Bronze HDHP",1520.00,860.00,0.00,12.50,2392.50
|
||||
MRI,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",1610.00,880.00,0.00,13.00,2503.00
|
||||
CT Scan,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",1600.00,750.00,0.00,8.00,2358.00
|
||||
CT Scan,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",1540.00,720.00,0.00,7.50,2267.50
|
||||
CT Scan,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",1750.00,820.00,0.00,9.00,2579.00
|
||||
X Ray,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",110.00,140.00,0.00,2.00,252.00
|
||||
X Ray,Green Valley Clinic,4.5,8.4,true,true,"Gold PPO, Family Plan - Silver EPO, Bronze HDHP",95.00,125.00,0.00,1.50,221.50
|
||||
X Ray,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",90.00,120.00,0.00,1.50,211.50
|
||||
X Ray,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",100.00,130.00,0.00,1.50,231.50
|
||||
Annual Physical Exam,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",80.00,180.00,0.00,5.00,265.00
|
||||
Annual Physical Exam,Green Valley Clinic,4.5,8.4,true,true,"Gold PPO, Family Plan - Silver EPO, Bronze HDHP",75.00,170.00,0.00,4.50,249.50
|
||||
Annual Physical Exam,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",85.00,200.00,0.00,4.50,289.50
|
||||
Annual Physical Exam,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",90.00,210.00,0.00,5.00,305.00
|
||||
Appendectomy,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",9100.00,5800.00,2200.00,195.00,17295.00
|
||||
Appendectomy,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",8200.00,5400.00,2100.00,180.00,15880.00
|
||||
Dental Cleaning,Green Valley Clinic,4.5,8.4,true,true,"Gold PPO, Family Plan - Silver EPO, Bronze HDHP",58.00,118.00,0.00,0.00,176.00
|
||||
Dental Cleaning,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO",60.00,120.00,0.00,0.00,180.00
|
||||
Dental Cleaning,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",55.00,115.00,0.00,0.00,170.00
|
||||
Vision Exam,Sunrise Health,4.3,15.1,true,false,"Gold PPO, Bronze HDHP",35.00,105.00,0.00,0.00,140.00
|
||||
Vision Exam,Green Valley Clinic,4.5,8.4,true,true,"Gold PPO, Family Plan - Silver EPO, Bronze HDHP",38.00,108.00,0.00,0.00,146.00
|
||||
Vision Exam,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO",40.00,110.00,0.00,0.00,150.00
|
||||
Blood Test,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",25.00,55.00,0.00,2.50,82.50
|
||||
Blood Test,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",28.00,55.00,0.00,3.00,86.00
|
||||
Angioplasty,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",9432.80,4774.57,1894.37,834.80,16936.54
|
||||
Angioplasty,Regional Medical Center,4.6,12.6,true,true,"Gold PPO, Family Plan - Silver EPO",8920.00,4520.00,1850.00,810.00,16100.00
|
||||
Ultrasound,City Hospital,4.7,5.2,true,true,"Gold PPO, Family Plan - Silver EPO, Medicare Advantage",380.00,290.00,0.00,3.00,673.00
|
||||
|
20
app/data/combined_email.txt
Normal file
20
app/data/combined_email.txt
Normal file
@@ -0,0 +1,20 @@
|
||||
From: dr.martinez@cityhospital.com
|
||||
Subject: Follow-up after appointment
|
||||
|
||||
Hi Charlie,
|
||||
|
||||
Following our visit last week, I'm recommending a follow-up CT scan
|
||||
to confirm the diagnosis. Please schedule within the next 2 weeks.
|
||||
|
||||
Best,
|
||||
Dr. Martinez
|
||||
|
||||
---
|
||||
|
||||
From: charlie.smith@gmail.com
|
||||
Subject: Re: Follow-up
|
||||
|
||||
Thanks Dr. Martinez. Will the CT scan be covered by my Gold PPO plan?
|
||||
Also, can I get a copy of the lab results from last visit?
|
||||
|
||||
Charlie
|
||||
51
app/data/historical_procedures.csv
Normal file
51
app/data/historical_procedures.csv
Normal file
@@ -0,0 +1,51 @@
|
||||
member_name,relationship,age,gender,procedure,procedure_type,location,date,in_network,member_plan,accepted_plans,cost_facility,cost_physician,cost_anesthesia,cost_medication,total_cost,facility_rating,notes
|
||||
Charlie Smith,Self,13,Male,Annual Physical Exam,preventive,City Hospital,2024-05-15,true,Gold PPO,"Gold PPO, Medicare Advantage",80.00,180.00,0.00,5.00,265.00,4.7,"Annual Physical Exam performed at City Hospital."
|
||||
Charlie Smith,Self,13,Male,Dental Cleaning,preventive,Green Valley Clinic,2025-01-10,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",60.00,120.00,0.00,0.00,180.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
|
||||
Charlie Smith,Self,13,Male,Vision Exam,preventive,Sunrise Health,2023-08-22,true,Gold PPO,"Gold PPO, Bronze HDHP",40.00,110.00,0.00,0.00,150.00,4.3,"Vision Exam performed at Sunrise Health."
|
||||
Charlie Smith,Self,13,Male,Blood Test,diagnostic,City Hospital,2024-11-04,true,Gold PPO,"Gold PPO, Medicare Advantage",25.00,55.00,0.00,2.50,82.50,4.7,"Cholesterol Panel performed at City Hospital."
|
||||
Alice Smith,Spouse,42,Female,Annual Physical Exam,preventive,Regional Medical Center,2024-09-18,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",95.00,210.00,0.00,4.50,309.50,4.6,"Annual Physical Exam performed at Regional Medical Center."
|
||||
Alice Smith,Spouse,42,Female,MRI,diagnostic,City Hospital,2023-11-02,true,Gold PPO,"Gold PPO, Medicare Advantage",1450.00,820.00,0.00,12.00,2282.00,4.7,"Lumbar MRI performed at City Hospital."
|
||||
Alice Smith,Spouse,42,Female,Dental Cleaning,preventive,Green Valley Clinic,2024-08-12,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",65.00,125.00,0.00,0.00,190.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
|
||||
Bob Johnson,Self,55,Male,Appendectomy,surgery,Regional Medical Center,2024-02-04,true,Family Plan - Silver EPO,"Family Plan - Silver EPO, Gold PPO",8200.00,5400.00,2100.00,180.00,15880.00,4.6,"Laparoscopic appendectomy performed at Regional Medical Center."
|
||||
Bob Johnson,Self,55,Male,Annual Physical Exam,preventive,Regional Medical Center,2024-10-22,false,Family Plan - Silver EPO,"Gold PPO",120.00,260.00,0.00,6.00,386.00,4.6,"Annual Physical Exam performed at Regional Medical Center."
|
||||
Bob Johnson,Self,55,Male,CT Scan,diagnostic,City Hospital,2024-02-03,true,Family Plan - Silver EPO,"Gold PPO, Medicare Advantage",1600.00,750.00,0.00,8.00,2358.00,4.7,"Pre-op CT abdomen/pelvis at City Hospital."
|
||||
Diana Roberts,Mother,67,Female,Annual Physical Exam,preventive,Sunrise Health,2024-07-09,true,Gold PPO,"Gold PPO, Bronze HDHP",95.00,220.00,0.00,5.00,320.00,4.3,"Annual Physical Exam performed at Sunrise Health."
|
||||
Diana Roberts,Mother,67,Female,Vision Exam,preventive,Sunrise Health,2024-10-14,true,Gold PPO,"Gold PPO, Bronze HDHP",40.00,115.00,0.00,0.00,155.00,4.3,"Vision Exam performed at Sunrise Health."
|
||||
Diana Roberts,Mother,67,Female,Blood Test,diagnostic,City Hospital,2025-02-18,true,Gold PPO,"Gold PPO, Medicare Advantage",28.00,60.00,0.00,3.00,91.00,4.7,"Cholesterol Panel performed at City Hospital."
|
||||
Diana Roberts,Mother,67,Female,MRI,diagnostic,Regional Medical Center,2023-06-30,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",1520.00,840.00,0.00,10.00,2370.00,4.6,"Knee MRI performed at Regional Medical Center."
|
||||
Ethan Smith,Son,11,Male,Annual Physical Exam,preventive,City Hospital,2025-04-02,true,Gold PPO,"Gold PPO, Medicare Advantage",70.00,170.00,0.00,4.00,244.00,4.7,"Annual Physical Exam performed at City Hospital."
|
||||
Ethan Smith,Son,11,Male,Dental Cleaning,preventive,Green Valley Clinic,2025-03-15,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",55.00,115.00,0.00,0.00,170.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
|
||||
Ethan Smith,Son,11,Male,Vision Exam,preventive,Sunrise Health,2024-12-08,true,Gold PPO,"Gold PPO, Bronze HDHP",35.00,105.00,0.00,0.00,140.00,4.3,"Vision Exam performed at Sunrise Health."
|
||||
Ethan Smith,Son,11,Male,X Ray,diagnostic,City Hospital,2024-09-21,true,Gold PPO,"Gold PPO, Medicare Advantage",105.00,135.00,0.00,2.00,242.00,4.7,"Wrist X Ray performed at City Hospital."
|
||||
Alice Smith,Spouse,42,Female,Blood Test,diagnostic,City Hospital,2024-04-19,true,Gold PPO,"Gold PPO, Medicare Advantage",30.00,58.00,0.00,3.50,91.50,4.7,"Lipid panel at City Hospital."
|
||||
Alice Smith,Spouse,42,Female,Vision Exam,preventive,Sunrise Health,2024-06-11,true,Gold PPO,"Gold PPO, Bronze HDHP",38.00,108.00,0.00,0.00,146.00,4.3,"Vision Exam performed at Sunrise Health."
|
||||
Bob Johnson,Self,55,Male,Dental Cleaning,preventive,Green Valley Clinic,2024-12-03,true,Family Plan - Silver EPO,"Family Plan - Silver EPO, Gold PPO",60.00,120.00,0.00,0.00,180.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
|
||||
Bob Johnson,Self,55,Male,Blood Test,diagnostic,Regional Medical Center,2025-01-22,true,Family Plan - Silver EPO,"Family Plan - Silver EPO, Gold PPO",28.00,55.00,0.00,3.00,86.00,4.6,"A1C panel performed at Regional Medical Center."
|
||||
Charlie Smith,Self,13,Male,X Ray,diagnostic,City Hospital,2025-03-28,true,Gold PPO,"Gold PPO, Medicare Advantage",110.00,140.00,0.00,2.00,252.00,4.7,"Ankle X Ray performed at City Hospital."
|
||||
Frank Lee,Father,72,Male,CT Scan,diagnostic,Regional Medical Center,2024-08-29,false,Bronze HDHP,"Gold PPO, Medicare Advantage",1750.00,820.00,0.00,9.00,2579.00,4.6,"Head CT performed at Regional Medical Center."
|
||||
Frank Lee,Father,72,Male,Annual Physical Exam,preventive,Sunrise Health,2024-11-19,true,Bronze HDHP,"Gold PPO, Bronze HDHP",95.00,225.00,0.00,5.50,325.50,4.3,"Annual Physical Exam performed at Sunrise Health."
|
||||
Frank Lee,Father,72,Male,Vision Exam,preventive,Sunrise Health,2024-09-02,true,Bronze HDHP,"Gold PPO, Bronze HDHP",42.00,118.00,0.00,0.00,160.00,4.3,"Vision Exam performed at Sunrise Health."
|
||||
Frank Lee,Father,72,Male,Blood Test,diagnostic,City Hospital,2025-02-25,true,Bronze HDHP,"Gold PPO, Medicare Advantage",30.00,58.00,0.00,4.00,92.00,4.7,"Comprehensive metabolic panel at City Hospital."
|
||||
Grace Wong,Daughter,16,Female,Annual Physical Exam,preventive,Green Valley Clinic,2025-02-11,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",75.00,175.00,0.00,4.00,254.00,4.5,"Annual Physical Exam performed at Green Valley Clinic."
|
||||
Grace Wong,Daughter,16,Female,Dental Cleaning,preventive,Green Valley Clinic,2024-11-30,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",58.00,118.00,0.00,0.00,176.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
|
||||
Grace Wong,Daughter,16,Female,Vision Exam,preventive,Sunrise Health,2025-01-26,true,Gold PPO,"Gold PPO, Bronze HDHP",36.00,106.00,0.00,0.00,142.00,4.3,"Vision Exam performed at Sunrise Health."
|
||||
Henry Park,Self,38,Male,MRI,diagnostic,City Hospital,2024-07-15,true,Family Plan - Silver EPO,"Gold PPO, Family Plan - Silver EPO",1500.00,830.00,0.00,11.00,2341.00,4.7,"Shoulder MRI performed at City Hospital."
|
||||
Henry Park,Self,38,Male,Annual Physical Exam,preventive,Regional Medical Center,2024-08-08,true,Family Plan - Silver EPO,"Gold PPO, Family Plan - Silver EPO",90.00,210.00,0.00,5.00,305.00,4.6,"Annual Physical Exam performed at Regional Medical Center."
|
||||
Henry Park,Self,38,Male,X Ray,diagnostic,City Hospital,2024-07-10,true,Family Plan - Silver EPO,"Gold PPO, Medicare Advantage",120.00,145.00,0.00,2.50,267.50,4.7,"Shoulder X Ray performed at City Hospital."
|
||||
Henry Park,Self,38,Male,Dental Cleaning,preventive,Green Valley Clinic,2024-10-04,true,Family Plan - Silver EPO,"Gold PPO, Family Plan - Silver EPO",62.00,122.00,0.00,0.00,184.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
|
||||
Isabel Cruz,Mother,69,Female,Appendectomy,surgery,City Hospital,2024-03-12,true,Gold PPO,"Gold PPO, Medicare Advantage",9100.00,5800.00,2200.00,195.00,17295.00,4.7,"Open appendectomy performed at City Hospital."
|
||||
Isabel Cruz,Mother,69,Female,CT Scan,diagnostic,City Hospital,2024-03-11,true,Gold PPO,"Gold PPO, Medicare Advantage",1620.00,780.00,0.00,8.50,2408.50,4.7,"Pre-op CT abdomen/pelvis at City Hospital."
|
||||
Isabel Cruz,Mother,69,Female,Annual Physical Exam,preventive,Sunrise Health,2024-06-22,true,Gold PPO,"Gold PPO, Bronze HDHP",95.00,225.00,0.00,5.50,325.50,4.3,"Annual Physical Exam performed at Sunrise Health."
|
||||
Isabel Cruz,Mother,69,Female,Blood Test,diagnostic,City Hospital,2025-01-30,true,Gold PPO,"Gold PPO, Medicare Advantage",32.00,62.00,0.00,3.50,97.50,4.7,"Comprehensive metabolic panel at City Hospital."
|
||||
Jack Miller,Self,29,Male,X Ray,diagnostic,Sunrise Health,2024-05-04,true,Bronze HDHP,"Gold PPO, Bronze HDHP",95.00,130.00,0.00,2.00,227.00,4.3,"Knee X Ray performed at Sunrise Health."
|
||||
Jack Miller,Self,29,Male,Annual Physical Exam,preventive,Sunrise Health,2025-02-06,true,Bronze HDHP,"Gold PPO, Bronze HDHP",85.00,200.00,0.00,4.50,289.50,4.3,"Annual Physical Exam performed at Sunrise Health."
|
||||
Jack Miller,Self,29,Male,Dental Cleaning,preventive,Green Valley Clinic,2024-10-17,true,Bronze HDHP,"Family Plan - Silver EPO",58.00,118.00,0.00,0.00,176.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
|
||||
Karen Davis,Spouse,46,Female,MRI,diagnostic,Regional Medical Center,2024-04-09,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",1480.00,810.00,0.00,11.00,2301.00,4.6,"Brain MRI performed at Regional Medical Center."
|
||||
Karen Davis,Spouse,46,Female,Annual Physical Exam,preventive,Regional Medical Center,2024-11-25,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",100.00,225.00,0.00,5.50,330.50,4.6,"Annual Physical Exam performed at Regional Medical Center."
|
||||
Karen Davis,Spouse,46,Female,Dental Cleaning,preventive,Green Valley Clinic,2024-09-30,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",60.00,120.00,0.00,0.00,180.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
|
||||
Liam OBrien,Son,9,Male,Annual Physical Exam,preventive,City Hospital,2025-03-05,true,Gold PPO,"Gold PPO, Medicare Advantage",70.00,170.00,0.00,4.00,244.00,4.7,"Annual Physical Exam performed at City Hospital."
|
||||
Liam OBrien,Son,9,Male,Vision Exam,preventive,Sunrise Health,2024-12-19,true,Gold PPO,"Gold PPO, Bronze HDHP",35.00,105.00,0.00,0.00,140.00,4.3,"Vision Exam performed at Sunrise Health."
|
||||
Liam OBrien,Son,9,Male,Dental Cleaning,preventive,Green Valley Clinic,2025-04-11,true,Gold PPO,"Gold PPO, Family Plan - Silver EPO",55.00,115.00,0.00,0.00,170.00,4.5,"Dental Cleaning performed at Green Valley Clinic."
|
||||
Maria Lopez,Daughter,19,Female,Annual Physical Exam,preventive,Green Valley Clinic,2025-01-08,true,Family Plan - Silver EPO,"Gold PPO, Family Plan - Silver EPO",80.00,185.00,0.00,4.50,269.50,4.5,"Annual Physical Exam performed at Green Valley Clinic."
|
||||
Maria Lopez,Daughter,19,Female,Blood Test,diagnostic,City Hospital,2025-02-14,true,Family Plan - Silver EPO,"Gold PPO, Medicare Advantage",28.00,55.00,0.00,3.00,86.00,4.7,"Iron panel performed at City Hospital."
|
||||
Maria Lopez,Daughter,19,Female,Vision Exam,preventive,Sunrise Health,2024-08-20,true,Family Plan - Silver EPO,"Gold PPO, Bronze HDHP",38.00,108.00,0.00,0.00,146.00,4.3,"Vision Exam performed at Sunrise Health."
|
||||
|
76
app/data/member_insights.json
Normal file
76
app/data/member_insights.json
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"result": {
|
||||
"member": {
|
||||
"name": "Charlie Smith",
|
||||
"date_of_birth": "2013-03-04",
|
||||
"plan": "Gold PPO",
|
||||
"member_id": "CS-001-2024"
|
||||
},
|
||||
"medical_plan": {
|
||||
"name": "Gold PPO",
|
||||
"deductible": 1500,
|
||||
"deductible_met": 850,
|
||||
"out_of_pocket_max": 6000,
|
||||
"out_of_pocket_met": 1200,
|
||||
"coinsurance": "20%",
|
||||
"primary_care_copay": 25,
|
||||
"specialist_copay": 50,
|
||||
"emergency_room_copay": 250
|
||||
},
|
||||
"pharmacy_plan": {
|
||||
"tier_1_copay": 10,
|
||||
"tier_2_copay": 30,
|
||||
"tier_3_copay": 60,
|
||||
"tier_4_coinsurance": "30%",
|
||||
"mail_order_available": true
|
||||
},
|
||||
"mental_health": {
|
||||
"covered_visits_per_year": 20,
|
||||
"visits_used": 4,
|
||||
"telehealth": true,
|
||||
"in_network_providers": "https://taller-wox.fitlabs.dev/docs/mental-health-providers"
|
||||
},
|
||||
"wellness": {
|
||||
"gym_reimbursement_annual": 300,
|
||||
"gym_reimbursement_used": 150,
|
||||
"annual_checkup_covered": true,
|
||||
"preventive_care_100_percent": true,
|
||||
"flu_shot_covered": true
|
||||
},
|
||||
"tax_documents": {
|
||||
"form_1095_available": true,
|
||||
"form_1095_url": "https://taller-wox.fitlabs.dev/docs/1095-2025.pdf",
|
||||
"instructions": "Tu formulario 1095 está disponible en el portal del afiliado bajo Documentos > Impuestos. Si no lo puedes acceder, llama al 1-800-FIT-CARE."
|
||||
},
|
||||
"overdue_procedures": [
|
||||
{
|
||||
"procedure": "Annual Physical Exam",
|
||||
"last_date": "2024-05-15",
|
||||
"recommended_frequency_months": 12,
|
||||
"due_since_months": 12,
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"procedure": "Dental Cleaning",
|
||||
"last_date": "2025-01-10",
|
||||
"recommended_frequency_months": 6,
|
||||
"due_since_months": 10,
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"procedure": "Vision Exam",
|
||||
"last_date": "2023-08-22",
|
||||
"recommended_frequency_months": 24,
|
||||
"due_since_months": 8,
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"procedure": "Blood Test - Cholesterol Panel",
|
||||
"last_date": "2024-11-04",
|
||||
"recommended_frequency_months": 12,
|
||||
"due_since_months": 6,
|
||||
"priority": "low"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
5
app/data/provider_email.txt
Normal file
5
app/data/provider_email.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
Patient presented with bilateral lower quadrant tenderness, WBC 14k,
|
||||
CRP elevated. Differential includes appendicitis vs diverticulitis.
|
||||
Recommending CT abdomen/pelvis with contrast STAT.
|
||||
CPT 74177 ordered. Pre-auth obtained: AUTH-2024-8821.
|
||||
Patient stable, NPO since midnight. Will reassess in 2h.
|
||||
0
app/data/reports_output/.gitkeep
Normal file
0
app/data/reports_output/.gitkeep
Normal file
1
app/data/schedule_response.txt
Normal file
1
app/data/schedule_response.txt
Normal file
@@ -0,0 +1 @@
|
||||
Para agendar una cita médica, sigue estos pasos: 1) Confirma con el afiliado el día y hora preferidos, y el tipo de procedimiento. 2) Verifica que el procedimiento esté cubierto por su plan (Gold PPO en este caso). 3) Llama al sistema de agendamiento de FIT Care al 1-800-FIT-CARE o entra al portal en https://taller-wox.fitlabs.dev/agenda. 4) Indica el procedimiento, el proveedor preferido (City Hospital, Green Valley Clinic, Sunrise Health o Regional Medical Center) y la fecha. 5) Confirma la cita y registra el número de confirmación. 6) Envía recordatorios automáticos al afiliado 24h y 1h antes. Recuerda: si el procedimiento requiere autorización previa, gestiónala antes de confirmar la cita.
|
||||
128
app/db.py
Normal file
128
app/db.py
Normal file
@@ -0,0 +1,128 @@
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS leads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
nombre TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
empresa TEXT NOT NULL,
|
||||
ip TEXT,
|
||||
user_agent TEXT,
|
||||
consent INTEGER NOT NULL DEFAULT 0,
|
||||
times_registered INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS downloads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
lead_email TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
ip TEXT,
|
||||
downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_email ON downloads(lead_email);
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_filename ON downloads(filename);
|
||||
"""
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _conn() -> Iterator[sqlite3.Connection]:
|
||||
settings = get_settings()
|
||||
db_path = Path(settings.db_path)
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with _conn() as conn:
|
||||
conn.executescript(SCHEMA)
|
||||
|
||||
|
||||
def upsert_lead(
|
||||
nombre: str,
|
||||
email: str,
|
||||
empresa: str,
|
||||
ip: str | None,
|
||||
user_agent: str | None,
|
||||
consent: bool,
|
||||
) -> int:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO leads (nombre, email, empresa, ip, user_agent, consent)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(email) DO UPDATE SET
|
||||
nombre = excluded.nombre,
|
||||
empresa = excluded.empresa,
|
||||
ip = excluded.ip,
|
||||
user_agent = excluded.user_agent,
|
||||
consent = excluded.consent,
|
||||
times_registered = times_registered + 1,
|
||||
last_seen = CURRENT_TIMESTAMP
|
||||
RETURNING id
|
||||
""",
|
||||
(nombre, email, empresa, ip, user_agent, 1 if consent else 0),
|
||||
)
|
||||
return cur.fetchone()["id"]
|
||||
|
||||
|
||||
def get_lead_by_email(email: str) -> dict | None:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM leads WHERE email = ?", (email,)
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def log_download(lead_email: str, filename: str, ip: str | None) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO downloads (lead_email, filename, ip) VALUES (?, ?, ?)",
|
||||
(lead_email, filename, ip),
|
||||
)
|
||||
|
||||
|
||||
def list_leads(limit: int = 100, offset: int = 0) -> list[dict]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM leads ORDER BY id ASC LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def stats() -> dict:
|
||||
with _conn() as conn:
|
||||
total_leads = conn.execute("SELECT COUNT(*) AS c FROM leads").fetchone()["c"]
|
||||
total_downloads = conn.execute("SELECT COUNT(*) AS c FROM downloads").fetchone()["c"]
|
||||
per_file = {
|
||||
r["filename"]: r["c"]
|
||||
for r in conn.execute(
|
||||
"SELECT filename, COUNT(*) AS c FROM downloads GROUP BY filename"
|
||||
).fetchall()
|
||||
}
|
||||
top_empresas = [
|
||||
{"empresa": r["empresa"], "count": r["c"]}
|
||||
for r in conn.execute(
|
||||
"SELECT empresa, COUNT(*) AS c FROM leads GROUP BY empresa ORDER BY c DESC LIMIT 5"
|
||||
).fetchall()
|
||||
]
|
||||
return {
|
||||
"total_leads": total_leads,
|
||||
"total_downloads": total_downloads,
|
||||
"downloads_por_archivo": per_file,
|
||||
"top_5_empresas": top_empresas,
|
||||
}
|
||||
136
app/frontend.py
Normal file
136
app/frontend.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from itsdangerous import BadSignature, SignatureExpired
|
||||
from pydantic import EmailStr
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import log_download, upsert_lead
|
||||
from app.security import (
|
||||
create_download_token,
|
||||
is_honeypot_filled,
|
||||
verify_download_token,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_TEMPLATES_DIR = Path(__file__).parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
|
||||
|
||||
DOWNLOADS = [
|
||||
{
|
||||
"filename": "taller-wox-tecnico.zip",
|
||||
"icon": "🧩",
|
||||
"title": "Material técnico",
|
||||
"description": "Specs OpenAPI, configs y artefactos para importar a watsonx Orchestrate.",
|
||||
},
|
||||
{
|
||||
"filename": "taller-wox-funcional.zip",
|
||||
"icon": "📚",
|
||||
"title": "Material funcional",
|
||||
"description": "Manual paso a paso del bootcamp y deck de slides.",
|
||||
},
|
||||
]
|
||||
ALLOWED_FILENAMES = {d["filename"] for d in DOWNLOADS}
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def landing(request: Request, error: str | None = None):
|
||||
error_msg = None
|
||||
if error == "token-invalido":
|
||||
error_msg = "El link expiró o es inválido. Regístrate de nuevo para descargar el material."
|
||||
return templates.TemplateResponse(
|
||||
"index.html",
|
||||
{"request": request, "error": error_msg},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
def register(
|
||||
request: Request,
|
||||
nombre: str = Form(..., min_length=2, max_length=80),
|
||||
email: EmailStr = Form(...),
|
||||
empresa: str = Form(..., min_length=2, max_length=100),
|
||||
consentimiento: str = Form(...),
|
||||
website: str | None = Form(default=""),
|
||||
):
|
||||
if consentimiento != "on":
|
||||
raise HTTPException(status_code=400, detail="Consentimiento requerido")
|
||||
|
||||
if is_honeypot_filled(website):
|
||||
fake_token = create_download_token(email="honeypot@discarded.local", nombre="x")
|
||||
return RedirectResponse(url=f"/descargas?token={fake_token}", status_code=303)
|
||||
|
||||
client_ip = request.client.host if request.client else None
|
||||
user_agent = request.headers.get("user-agent")
|
||||
|
||||
upsert_lead(
|
||||
nombre=nombre,
|
||||
email=str(email),
|
||||
empresa=empresa,
|
||||
ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
consent=True,
|
||||
)
|
||||
|
||||
token = create_download_token(email=str(email), nombre=nombre)
|
||||
return RedirectResponse(url=f"/descargas?token={token}", status_code=303)
|
||||
|
||||
|
||||
@router.get("/descargas", response_class=HTMLResponse)
|
||||
def descargas(request: Request, token: str | None = None):
|
||||
if not token:
|
||||
return RedirectResponse(url="/?error=token-invalido", status_code=307)
|
||||
try:
|
||||
data = verify_download_token(token)
|
||||
except (SignatureExpired, BadSignature):
|
||||
return RedirectResponse(url="/?error=token-invalido", status_code=307)
|
||||
|
||||
settings = get_settings()
|
||||
material_dir = Path(settings.material_dir)
|
||||
downloads_view = []
|
||||
for d in DOWNLOADS:
|
||||
path = material_dir / d["filename"]
|
||||
downloads_view.append({
|
||||
**d,
|
||||
"available": path.exists(),
|
||||
"size_mb": round(path.stat().st_size / (1024 * 1024), 1) if path.exists() else 0,
|
||||
})
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"descargas.html",
|
||||
{
|
||||
"request": request,
|
||||
"nombre": data.get("nombre", "amig@"),
|
||||
"token": token,
|
||||
"downloads": downloads_view,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/download/{filename}")
|
||||
def download(request: Request, filename: str, token: str | None = None):
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Missing token")
|
||||
try:
|
||||
data = verify_download_token(token)
|
||||
except (SignatureExpired, BadSignature):
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
|
||||
if filename not in ALLOWED_FILENAMES:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
path = Path(get_settings().material_dir) / filename
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not available yet")
|
||||
|
||||
client_ip = request.client.host if request.client else None
|
||||
log_download(lead_email=data["email"], filename=filename, ip=client_ip)
|
||||
|
||||
return FileResponse(
|
||||
path=str(path),
|
||||
filename=filename,
|
||||
media_type="application/zip",
|
||||
)
|
||||
47
app/main.py
Normal file
47
app/main.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app import admin, benefits_api, frontend, reports_api
|
||||
from app.config import get_settings
|
||||
from app.db import init_db
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
app = FastAPI(title="taller-wox.fitlabs.dev", version="1.0.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
_STATIC_DIR = Path(__file__).parent.parent / "static"
|
||||
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
|
||||
|
||||
app.include_router(frontend.router)
|
||||
app.include_router(benefits_api.router)
|
||||
app.include_router(reports_api.router)
|
||||
app.include_router(admin.router)
|
||||
|
||||
_reports_dir = Path(settings.reports_output_dir)
|
||||
_reports_dir.mkdir(parents=True, exist_ok=True)
|
||||
app.mount(
|
||||
"/api/reports/output",
|
||||
StaticFiles(directory=str(_reports_dir)),
|
||||
name="reports_output",
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup() -> None:
|
||||
init_db()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "base_url": settings.base_url}
|
||||
129
app/reports_api.py
Normal file
129
app/reports_api.py
Normal file
@@ -0,0 +1,129 @@
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import plotly.express as px
|
||||
import plotly.io as pio
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||
|
||||
_DATA_DIR = Path(__file__).parent / "data"
|
||||
_TEMPLATES_DIR = Path(__file__).parent / "templates"
|
||||
|
||||
_env = Environment(
|
||||
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
|
||||
autoescape=select_autoescape(["html"]),
|
||||
)
|
||||
|
||||
|
||||
CARE_REPORT_PRESET = [
|
||||
{"element_type": "header", "parameters": {"title": "Care Report"}},
|
||||
{
|
||||
"element_type": "overview",
|
||||
"parameters": {
|
||||
"prompt": "Summarize this email exchange in 3 bullet points: ",
|
||||
"text_file": "./data/combined_email.txt",
|
||||
"title": "Customer Overview",
|
||||
},
|
||||
},
|
||||
{"element_type": "claim_review_chart", "parameters": {}},
|
||||
{
|
||||
"element_type": "table",
|
||||
"parameters": {
|
||||
"csv_file": "./data/aetna_claim_review_summary.csv",
|
||||
"title": "Claim Review Summary",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _resolve_path(relative: str) -> Path:
|
||||
cleaned = relative.lstrip("./")
|
||||
if cleaned.startswith("data/"):
|
||||
cleaned = cleaned[len("data/"):]
|
||||
return _DATA_DIR / cleaned
|
||||
|
||||
|
||||
def _render_header(params: dict) -> str:
|
||||
title = params.get("title", "")
|
||||
return f"<h2 style='text-align:center;'>{title}</h2>"
|
||||
|
||||
|
||||
def _render_overview(params: dict) -> str:
|
||||
title = params.get("title", "Overview")
|
||||
text_file = _resolve_path(params.get("text_file", ""))
|
||||
if not text_file.exists():
|
||||
raise HTTPException(status_code=400, detail=f"text_file not found: {params.get('text_file')}")
|
||||
text = text_file.read_text(encoding="utf-8")
|
||||
paragraphs = "".join(f"<p>{line}</p>" for line in text.splitlines() if line.strip())
|
||||
return f"<h2>{title}</h2><div style='background:#F5F7FA;padding:16px;border-radius:8px;'>{paragraphs}</div>"
|
||||
|
||||
|
||||
def _render_claim_chart() -> str:
|
||||
df = pd.read_csv(_DATA_DIR / "aetna_claim_review_summary.csv")
|
||||
fig = px.bar(
|
||||
df,
|
||||
x="CPT_Code",
|
||||
y=["Charged_Amount", "Allowed_Amount", "Patient_Responsibility"],
|
||||
barmode="group",
|
||||
title="Claim Review by CPT Code",
|
||||
color_discrete_sequence=["#0A1F44", "#1E4FA8", "#FF7A00"],
|
||||
)
|
||||
return pio.to_html(fig, full_html=False, include_plotlyjs="cdn")
|
||||
|
||||
|
||||
def _render_table(params: dict) -> str:
|
||||
title = params.get("title", "")
|
||||
csv_file = _resolve_path(params.get("csv_file", ""))
|
||||
if not csv_file.exists():
|
||||
raise HTTPException(status_code=400, detail=f"csv_file not found: {params.get('csv_file')}")
|
||||
df = pd.read_csv(csv_file)
|
||||
return f"<h2>{title}</h2>" + df.to_html(index=False, classes="report-table", border=0)
|
||||
|
||||
|
||||
_RENDERERS = {
|
||||
"header": lambda params: _render_header(params),
|
||||
"overview": lambda params: _render_overview(params),
|
||||
"claim_review_chart": lambda _params: _render_claim_chart(),
|
||||
"table": lambda params: _render_table(params),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/generate-report")
|
||||
def generate_report(payload: dict):
|
||||
try:
|
||||
layout = json.loads(payload["layout_config"])
|
||||
except (KeyError, json.JSONDecodeError) as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid layout_config: {exc}")
|
||||
|
||||
expanded: list[dict] = []
|
||||
for item in layout:
|
||||
if item == "care_report":
|
||||
expanded.extend(CARE_REPORT_PRESET)
|
||||
elif isinstance(item, dict):
|
||||
expanded.append(item)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown layout item: {item!r}")
|
||||
|
||||
parts: list[str] = []
|
||||
for el in expanded:
|
||||
kind = el.get("element_type")
|
||||
if kind not in _RENDERERS:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown element_type: {kind}")
|
||||
parts.append(_RENDERERS[kind](el.get("parameters", {})))
|
||||
|
||||
template = _env.get_template("report.html")
|
||||
html = template.render(content="\n".join(parts))
|
||||
|
||||
settings = get_settings()
|
||||
output_dir = Path(settings.reports_output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
report_id = f"{uuid.uuid4().hex[:12]}.html"
|
||||
(output_dir / report_id).write_text(html, encoding="utf-8")
|
||||
|
||||
return {"public_url": f"{settings.base_url}/api/reports/output/{report_id}"}
|
||||
41
app/security.py
Normal file
41
app/security.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import secrets
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
from itsdangerous import URLSafeTimedSerializer
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
_basic = HTTPBasic()
|
||||
|
||||
|
||||
def _serializer() -> URLSafeTimedSerializer:
|
||||
return URLSafeTimedSerializer(get_settings().secret_key, salt="download")
|
||||
|
||||
|
||||
def create_download_token(email: str, nombre: str) -> str:
|
||||
return _serializer().dumps({"email": email, "nombre": nombre})
|
||||
|
||||
|
||||
def verify_download_token(token: str) -> dict:
|
||||
settings = get_settings()
|
||||
max_age_seconds = max(1, settings.token_expiry_hours * 3600)
|
||||
return _serializer().loads(token, max_age=max_age_seconds)
|
||||
|
||||
|
||||
def is_honeypot_filled(value: str | None) -> bool:
|
||||
return bool(value)
|
||||
|
||||
|
||||
def require_admin(credentials: HTTPBasicCredentials = Depends(_basic)) -> str:
|
||||
settings = get_settings()
|
||||
user_ok = secrets.compare_digest(credentials.username, settings.admin_user)
|
||||
pass_ok = secrets.compare_digest(credentials.password, settings.admin_pass)
|
||||
if not (user_ok and pass_ok):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials",
|
||||
headers={"WWW-Authenticate": "Basic"},
|
||||
)
|
||||
return credentials.username
|
||||
36
app/templates/base.html
Normal file
36
app/templates/base.html
Normal file
@@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Bootcamp Agentic AI — watsonx Orchestrate | FactorIT{% endblock %}</title>
|
||||
<meta name="description" content="Construye tu primer agente de IA en 4 horas con IBM watsonx Orchestrate. Material completo del bootcamp para descarga.">
|
||||
|
||||
<meta property="og:title" content="Bootcamp Agentic AI — watsonx Orchestrate | FactorIT">
|
||||
<meta property="og:description" content="Construye tu primer agente de IA en 4 horas con IBM watsonx Orchestrate.">
|
||||
<meta property="og:url" content="https://taller-wox.fitlabs.dev/">
|
||||
|
||||
<link rel="icon" href="/static/img/LogoFIT.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<a class="brand" href="/"><img src="/static/img/LogoFIT.png" alt="FactorIT" height="36"></a>
|
||||
<span class="powered-by">powered by <strong>IBM watsonx Orchestrate</strong></span>
|
||||
</header>
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="footer-inner">
|
||||
<p><strong>FactorIT · FIT</strong> — Material del bootcamp · taller-wox.fitlabs.dev</p>
|
||||
<p class="copyright">© 2026 FactorIT — Todos los derechos reservados</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="/static/js/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
25
app/templates/descargas.html
Normal file
25
app/templates/descargas.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Material del bootcamp — {{ nombre }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="descargas-section">
|
||||
<h1 class="hello">¡Hola {{ nombre }}!</h1>
|
||||
<p class="hello-sub">Acá tienes todo el material del bootcamp.</p>
|
||||
|
||||
<div class="download-cards">
|
||||
{% for d in downloads %}
|
||||
<article class="download-card">
|
||||
<div class="dl-icon">{{ d.icon }}</div>
|
||||
<h2>{{ d.title }}</h2>
|
||||
<p>{{ d.description }}</p>
|
||||
<p class="size">{% if d.available %}{{ d.size_mb }} MB{% else %}Próximamente{% endif %}</p>
|
||||
{% if d.available %}
|
||||
<a class="btn btn-primary" href="/download/{{ d.filename }}?token={{ token }}">Descargar</a>
|
||||
{% else %}
|
||||
<span class="btn btn-disabled" title="Archivo aún no disponible">No disponible</span>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
63
app/templates/index.html
Normal file
63
app/templates/index.html
Normal file
@@ -0,0 +1,63 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% if error %}
|
||||
<div class="banner banner-error" role="alert">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="hero">
|
||||
<div class="hero-inner">
|
||||
<p class="eyebrow">FACTORIT · FIT</p>
|
||||
<h1>Bootcamp Agentic AI con watsonx Orchestrate</h1>
|
||||
<p class="subtitle">Construye tu primer agente de IA en 4 horas.</p>
|
||||
<a class="btn btn-primary" href="#descargas">Acceder al material →</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cards-section">
|
||||
<h2 class="section-title">¿Qué vas a construir?</h2>
|
||||
<div class="cards">
|
||||
<article class="card"><div class="icon">⚙️</div><h3>Tu primer agente</h3><p>Conecta una API real a un agente conversacional sin escribir código.</p></article>
|
||||
<article class="card"><div class="icon">📚</div><h3>Multi-agente con RAG</h3><p>Compón agentes especializados con base de conocimiento documental.</p></article>
|
||||
<article class="card"><div class="icon">📊</div><h3>Reportes y APIs</h3><p>Genera reportes ejecutivos invocando endpoints en vivo.</p></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stats-section">
|
||||
<h2 class="section-title">El taller en números</h2>
|
||||
<div class="stats">
|
||||
<div class="stat"><span class="num">4h</span><span class="label">Duración</span></div>
|
||||
<div class="stat"><span class="num">6</span><span class="label">Módulos</span></div>
|
||||
<div class="stat"><span class="num">0</span><span class="label">Líneas de código</span></div>
|
||||
<div class="stat"><span class="num">100%</span><span class="label">Hands-on</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="descargas" class="form-section">
|
||||
<h2 class="section-title">Descarga todo el material</h2>
|
||||
<p class="form-intro">Registra tus datos para acceder al kit completo del bootcamp.</p>
|
||||
<form class="register-form" method="post" action="/register" autocomplete="on">
|
||||
<div class="field">
|
||||
<label for="nombre">Nombre completo</label>
|
||||
<input type="text" id="nombre" name="nombre" minlength="2" maxlength="80" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="email">Email corporativo</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="empresa">Empresa</label>
|
||||
<input type="text" id="empresa" name="empresa" minlength="2" maxlength="100" required>
|
||||
</div>
|
||||
<div class="field field-checkbox">
|
||||
<input type="checkbox" id="consentimiento" name="consentimiento" required>
|
||||
<label for="consentimiento">Acepto que FactorIT use mis datos para enviarme información del bootcamp y comunicaciones futuras. No spam — solo lo importante.</label>
|
||||
</div>
|
||||
<div class="hp-field" aria-hidden="true">
|
||||
<label for="website">Website</label>
|
||||
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-large">Acceder al material</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
21
app/templates/report.html
Normal file
21
app/templates/report.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Care Report</title>
|
||||
<style>
|
||||
body { font-family: 'Inter', system-ui, sans-serif; max-width: 960px; margin: 40px auto; padding: 20px; color: #1A1A1A; }
|
||||
h2 { color: #0A1F44; border-bottom: 3px solid #FF7A00; padding-bottom: 8px; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 16px 0; }
|
||||
th { background: #0A1F44; color: white; padding: 10px; text-align: left; }
|
||||
td { padding: 8px; border-bottom: 1px solid #D8DEE5; }
|
||||
.eyebrow { color: #00B5D8; font-weight: 700; }
|
||||
footer { margin-top: 40px; color: #5A6473; font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><p class="eyebrow">FACTORIT · FIT</p></header>
|
||||
{{ content | safe }}
|
||||
<footer>Generado por AskReporting · taller-wox.fitlabs.dev</footer>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user