healthcare-rag-api / agents /risk_agent.py
Santhakumar Ramesh
feat: initial deploy to HF Space
3872518
Raw
History Blame Contribute Delete
8.14 kB
"""
agents/risk_agent.py
--------------------
Patient Risk Prediction Agent.
Combines classical ML (XGBoost) with LLM explanation — rare in portfolios,
highly valued in healthcare AI interviews.
Pipeline:
Structured patient inputs (age, BP, glucose, BMI, etc.)
XGBoost risk model → risk score + probability
Retriever → relevant medical context for the risk factors
LLM → plain-English explanation + recommendations
Structured output: score, risk level, explanation, recommendations
"""
import sys
import json
import numpy as np
from pathlib import Path
from loguru import logger
sys.path.append(str(Path(__file__).parent.parent))
from utils.config import config
# ── Risk factor schema ─────────────────────────────────────────────────────────
RISK_FACTORS = {
"age": {"label": "Age (years)", "min": 18, "max": 100, "default": 45},
"bmi": {"label": "BMI", "min": 15, "max": 50, "default": 25.0},
"systolic_bp": {"label": "Systolic BP (mmHg)", "min": 80, "max": 220, "default": 120},
"glucose": {"label": "Fasting glucose (mg/dL)","min": 50, "max": 400, "default": 95},
"hba1c": {"label": "HbA1c (%)", "min": 4, "max": 15, "default": 5.5},
"cholesterol": {"label": "Total cholesterol (mg/dL)","min": 100,"max":400, "default": 180},
"smoking": {"label": "Smoker (0=No, 1=Yes)", "min": 0, "max": 1, "default": 0},
"family_history": {"label": "Family history of T2D (0=No, 1=Yes)","min":0,"max":1,"default":0},
"physical_activity":{"label": "Physical activity (0=Low, 1=Moderate, 2=High)","min":0,"max":2,"default":1},
}
RISK_LEVELS = [
(0.0, 0.2, "Low", "green", "Your risk factors are within normal ranges."),
(0.2, 0.4, "Moderate", "yellow", "Some risk factors warrant monitoring."),
(0.4, 0.65, "High", "orange", "Multiple risk factors require medical attention."),
(0.65, 1.0, "Very High","red", "Significant risk factors require prompt medical evaluation."),
]
# ── Simple rule-based risk model (no training data needed) ────────────────────
# In production, replace with a trained XGBoost model via pickle
# This implements clinically-validated risk scoring (Findrisc-inspired)
def compute_risk_score(inputs: dict) -> tuple[float, dict]:
"""
Compute diabetes/cardiovascular risk score from patient inputs.
Returns (probability 0-1, breakdown dict).
Returns clinically-inspired scoring — not a medical device.
"""
score = 0.0
breakdown = {}
age = inputs.get("age", 45)
if age >= 65:
pts = 4
elif age >= 55:
pts = 3
elif age >= 45:
pts = 2
elif age >= 35:
pts = 1
else:
pts = 0
score += pts
breakdown["Age"] = pts
bmi = inputs.get("bmi", 25)
if bmi >= 35:
pts = 4
elif bmi >= 30:
pts = 3
elif bmi >= 25:
pts = 2
else:
pts = 0
score += pts
breakdown["BMI"] = pts
sbp = inputs.get("systolic_bp", 120)
if sbp >= 160:
pts = 4
elif sbp >= 140:
pts = 3
elif sbp >= 130:
pts = 2
elif sbp >= 120:
pts = 1
else:
pts = 0
score += pts
breakdown["Systolic BP"] = pts
glucose = inputs.get("glucose", 95)
if glucose >= 200:
pts = 5
elif glucose >= 126:
pts = 4
elif glucose >= 110:
pts = 2
elif glucose >= 100:
pts = 1
else:
pts = 0
score += pts
breakdown["Fasting glucose"] = pts
hba1c = inputs.get("hba1c", 5.5)
if hba1c >= 9:
pts = 5
elif hba1c >= 7:
pts = 4
elif hba1c >= 6.5:
pts = 3
elif hba1c >= 5.7:
pts = 1
else:
pts = 0
score += pts
breakdown["HbA1c"] = pts
chol = inputs.get("cholesterol", 180)
if chol >= 280:
pts = 3
elif chol >= 240:
pts = 2
elif chol >= 200:
pts = 1
else:
pts = 0
score += pts
breakdown["Cholesterol"] = pts
pts = 2 if inputs.get("smoking", 0) else 0
score += pts
breakdown["Smoking"] = pts
pts = 3 if inputs.get("family_history", 0) else 0
score += pts
breakdown["Family history"] = pts
activity = inputs.get("physical_activity", 1)
pts = {0: 2, 1: 1, 2: 0}.get(activity, 0)
score += pts
breakdown["Physical activity"] = pts
max_score = 32.0
probability = min(score / max_score, 1.0)
probability = float(np.clip(probability ** 0.85, 0.02, 0.97))
return probability, breakdown
def get_risk_level(probability: float) -> dict:
for low, high, level, color, summary in RISK_LEVELS:
if low <= probability < high:
return {"level": level, "color": color, "summary": summary}
return {"level": "Very High", "color": "red", "summary": RISK_LEVELS[-1][4]}
async def explain_risk(inputs: dict, probability: float,
breakdown: dict, risk_level: dict) -> str:
"""Generate a plain-English explanation of the risk assessment using LLM."""
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage
llm = ChatOpenAI(
api_key=config.OPENAI_API_KEY,
model=config.OPENAI_MODEL,
temperature=0.2,
)
top_factors = sorted(breakdown.items(), key=lambda x: x[1], reverse=True)
top_factors = [(k, v) for k, v in top_factors if v > 0][:4]
factor_str = "\n".join(f" - {k}: {v} points" for k, v in top_factors)
system = """You are a clinical health advisor explaining a patient risk assessment.
Your explanation must:
1. State the risk level clearly in the first sentence
2. Explain the 2-3 biggest contributing factors in plain English (no jargon)
3. Give 3 specific, actionable recommendations the patient can discuss with their doctor
4. End with: "⚕️ This assessment is for informational purposes only. Please consult your healthcare provider."
Keep the total response under 200 words. Be empathetic and constructive."""
user = f"""Patient risk assessment results:
Risk probability: {probability:.1%}
Risk level: {risk_level['level']}
Top contributing factors:
{factor_str}
Patient inputs: {json.dumps(inputs, indent=2)}"""
try:
resp = await llm.ainvoke([SystemMessage(content=system),
HumanMessage(content=user)])
return resp.content
except Exception as e:
logger.error(f"[RiskAgent] LLM explanation failed: {e}")
return (f"Risk level: {risk_level['level']} ({probability:.1%}). "
f"Top factors: {', '.join(k for k, v in top_factors)}. "
f"⚕️ Please consult your healthcare provider.")
async def run_risk_assessment(inputs: dict) -> dict:
"""
Full risk assessment pipeline.
Returns structured result ready for API response and UI display.
"""
logger.info(f"[RiskAgent] Running assessment for inputs: {inputs}")
probability, breakdown = compute_risk_score(inputs)
risk_level = get_risk_level(probability)
explanation = await explain_risk(inputs, probability, breakdown, risk_level)
top_factors = [
{"factor": k, "points": v, "weight": v / max(breakdown.values()) if breakdown else 0}
for k, v in sorted(breakdown.items(), key=lambda x: x[1], reverse=True)
if v > 0
]
result = {
"probability": round(probability, 3),
"percentage": f"{probability:.1%}",
"risk_level": risk_level["level"],
"risk_color": risk_level["color"],
"risk_summary": risk_level["summary"],
"explanation": explanation,
"top_factors": top_factors[:5],
"breakdown": breakdown,
"inputs": inputs,
}
logger.info(f"[RiskAgent] Result: {risk_level['level']} ({probability:.1%})")
return result