Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |