From 2e06ef96c353881951086038b2c0361b05a53171 Mon Sep 17 00:00:00 2001
From: muoimeo <bvnminh6a01@gmail.com>
Date: Tue, 22 Apr 2025 01:49:58 +0700
Subject: [PATCH] 97% add ML prediction printed out and top 3 stats affected
 the decision

---
 app/models/encounter.py                       |   6 +
 app/models/patient_dietitian_assignment.py    |   2 +-
 app/routes/dashboard.py                       |  68 +-
 app/routes/dietitian.py                       |  24 +-
 app/routes/patients.py                        | 736 ++++++++++++++----
 app/templates/base.html                       |   4 +-
 app/templates/dashboard.html                  | 119 ++-
 app/templates/dietitian_dashboard.html        |   2 +-
 app/templates/encounter_measurements.html     |  60 +-
 app/templates/patient_detail.html             |  40 +-
 app/templates/patients.html                   |  93 ++-
 app/templates/support.html                    |  44 +-
 .../versions/fb934f468320_add_ml_pred.py      |  50 ++
 model/train.py                                |  68 +-
 model/train_model/imputer.pkl                 | Bin 939 -> 939 bytes
 model/train_model/referral_model.pkl          | Bin 50987411 -> 50987411 bytes
 model/train_model/scaler.pkl                  | Bin 953 -> 953 bytes
 requirements.txt                              |   3 +-
 18 files changed, 1008 insertions(+), 311 deletions(-)
 create mode 100644 migrations/versions/fb934f468320_add_ml_pred.py

diff --git a/app/models/encounter.py b/app/models/encounter.py
index 9e26d8e..8e51f9a 100644
--- a/app/models/encounter.py
+++ b/app/models/encounter.py
@@ -5,6 +5,8 @@ from app.models.patient import Patient
 from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Enum, Boolean
 from sqlalchemy.orm import relationship
 import enum
+from sqlalchemy.dialects.postgresql import JSONB
+from sqlalchemy.types import JSON
 
 class EncounterStatus(enum.Enum):
     NOT_STARTED = "Not started"
@@ -27,6 +29,10 @@ class Encounter(db.Model):
     dietitian_id = Column(Integer, ForeignKey('users.userID'), nullable=True)
     needs_intervention = Column(Boolean, nullable=True, default=None)
     diagnosis = Column(String(255))
+    ml_needs_intervention = Column(db.Boolean, nullable=True, comment='Result of ML prediction (True=Needs Intervention)')
+    ml_prediction_time = Column(db.DateTime, nullable=True, comment='Timestamp of the last ML prediction run')
+    ml_top_features_json = Column(db.JSON, nullable=True, comment='JSON array of top influential features from SHAP')
+    ml_limit_breaches_json = Column(db.JSON, nullable=True, comment='JSON array of features breaching physiological limits')
 
     patient = relationship('Patient', back_populates='encounters')
     assigned_dietitian = relationship('User', foreign_keys=[dietitian_id], backref='assigned_encounters')
diff --git a/app/models/patient_dietitian_assignment.py b/app/models/patient_dietitian_assignment.py
index 01f005e..737ede7 100644
--- a/app/models/patient_dietitian_assignment.py
+++ b/app/models/patient_dietitian_assignment.py
@@ -6,7 +6,7 @@ class PatientDietitianAssignment(db.Model):
     __tablename__ = 'patient_dietitian_assignments'
 
     id = db.Column(INTEGER(unsigned=True), primary_key=True)
-    patient_id = db.Column(db.String(50), db.ForeignKey('patients.id', ondelete='CASCADE'), nullable=False, index=True)
+    patient_id = db.Column(db.String(50), db.ForeignKey('patients.patientID', ondelete='CASCADE'), nullable=False, index=True)
     dietitian_id = db.Column(db.Integer, db.ForeignKey('users.userID', ondelete='CASCADE'), nullable=False, index=True)
     assignment_date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, index=True)
     end_date = db.Column(db.DateTime, nullable=True) # Ngày kết thúc assignment (nếu có)
diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py
index 9d480f6..b690144 100644
--- a/app/routes/dashboard.py
+++ b/app/routes/dashboard.py
@@ -5,7 +5,7 @@ from app.models.patient import Patient, PatientStatus
 from app.models.encounter import Encounter
 from app.models.referral import Referral, ReferralStatus
 from app.models.procedure import Procedure
-from app.models.report import Report
+from app.models.report import Report, ReportStatus
 from app.models.measurement import PhysiologicalMeasurement
 from app.models.user import User
 from app.models.dietitian import Dietitian
@@ -34,14 +34,60 @@ def index():
     if current_user.role == 'Dietitian':
         return redirect(url_for('dietitian.dashboard'))
         
+    # Lấy ngày hiện tại và hôm qua
+    today = datetime.utcnow().date()
+    yesterday = today - timedelta(days=1)
+
     # Get statistics for dashboard cards
+    total_patients = Patient.query.count()
+    # Tính số lượng bệnh nhân hôm qua (lấy tất cả trừ đi những bệnh nhân được thêm vào hôm nay)
+    patients_added_today = Patient.query.filter(func.date(Patient.created_at) == today).count()
+    total_patients_yesterday = total_patients - patients_added_today
+    
+    # Tính phần trăm tăng của bệnh nhân so với hôm qua
+    patients_percent_change = 0
+    if total_patients_yesterday > 0:
+        patients_percent_change = (patients_added_today / total_patients_yesterday) * 100
+    
+    # Đếm số referrals được tạo HÔM NAY
+    todays_referrals = Referral.query.filter(
+        func.date(Referral.referralRequestedDateTime) == today
+    ).count()
+    
+    # Đếm số referrals được tạo HÔM QUA để tính % thay đổi
+    yesterdays_referrals = Referral.query.filter(
+        func.date(Referral.referralRequestedDateTime) == yesterday
+    ).count()
+    
+    # Tính phần trăm thay đổi so với hôm qua
+    referrals_percent_change = 0
+    if yesterdays_referrals > 0:
+        referrals_percent_change = ((todays_referrals - yesterdays_referrals) / yesterdays_referrals) * 100
+    
+    # Đếm số reports hoàn thành HÔM NAY
+    completed_reports_today = Report.query.filter(
+        func.date(Report.completed_date) == today,
+        Report.status == ReportStatus.COMPLETED
+    ).count()
+    
+    # Đếm số reports hoàn thành HÔM QUA
+    completed_reports_yesterday = Report.query.filter(
+        func.date(Report.completed_date) == yesterday,
+        Report.status == ReportStatus.COMPLETED
+    ).count()
+    
+    # Tính phần trăm thay đổi so với hôm qua
+    reports_percent_change = 0
+    if completed_reports_yesterday > 0:
+        reports_percent_change = ((completed_reports_today - completed_reports_yesterday) / completed_reports_yesterday) * 100
+    
     stats = {
-        'total_patients': Patient.query.count(),
-        'new_referrals': Referral.query.filter(Referral.referral_status == ReferralStatus.DIETITIAN_UNASSIGNED).count(),
-        'procedures_today': Procedure.query.filter(
-            func.date(Procedure.procedureDateTime) == func.current_date()
-        ).count(),
-        'pending_reports': Report.query.filter(Report.status == 'Pending').count()
+        'total_patients': total_patients,
+        'patients_percent_change': patients_percent_change,
+        'todays_referrals': todays_referrals,
+        'referrals_percent_change': referrals_percent_change,
+        'completed_reports_today': completed_reports_today,
+        'reports_percent_change': reports_percent_change
     }
     
     # Get recent patients
@@ -139,12 +185,12 @@ def index():
     patient_status_stats = {format_status_label(status): count for status, count in patient_status_stats_query}
     # -----------------------------------
 
-    # --- Report Status Distribution (Using String) ---
+    # --- Report Status Distribution (Using Enum) ---
     report_status_stats_query = db.session.query(
-        Report.status, # String column
+        Report.status, # Enum column
         func.count().label('count')
     ).group_by(Report.status).all()
-    # Use the string status directly as key, apply formatting if needed
+    # Use the enum value as key, apply formatting if needed
     report_status_stats = {status.value: count for status, count in report_status_stats_query if status}
     # ---------------------------------------------------
 
@@ -187,7 +233,7 @@ def index():
         referral_timeline=referral_timeline,
         dietitian_workload=dietitian_workload_chart_data,
         patient_status_stats=patient_status_stats, # Pass formatted dict
-        report_status_stats=report_status_stats,   # Pass dict with string keys
+        report_status_stats=report_status_stats,   # Pass dict with Enum values as keys
         referral_status_stats=referral_status_stats, # Pass formatted dict
         alerts=alerts,
         PatientStatus=PatientStatus # Pass Enum for template logic if needed
diff --git a/app/routes/dietitian.py b/app/routes/dietitian.py
index 5038d64..b62cccf 100644
--- a/app/routes/dietitian.py
+++ b/app/routes/dietitian.py
@@ -85,12 +85,26 @@ def dashboard():
         'Obese': 0,
         'Unknown': 0
     }
+    
     for p in assigned_patient_list:
-        category = p.get_bmi_category() # Sử dụng helper method từ model Patient
-        if category in bmi_categories:
-            bmi_categories[category] += 1
-        else:
-             bmi_categories['Unknown'] += 1
+        # Tính BMI để đảm bảo giá trị được cập nhật
+        if p.height and p.weight:
+            p.calculate_bmi()
+            
+        category = p.get_bmi_category()
+        
+        # Ánh xạ các giá trị tiếng Việt sang tiếng Anh để phù hợp với keys trong bmi_categories
+        category_mapping = {
+            'Thiếu cân': 'Underweight',
+            'Bình thường': 'Normal',
+            'Thừa cân': 'Overweight',
+            'Béo phì': 'Obese',
+            'Not Available': 'Unknown'
+        }
+        
+        mapped_category = category_mapping.get(category, 'Unknown')
+        bmi_categories[mapped_category] += 1
+        
     bmi_chart_data = {
         'labels': list(bmi_categories.keys()),
         'data': list(bmi_categories.values())
diff --git a/app/routes/patients.py b/app/routes/patients.py
index c887441..d1aefe7 100644
--- a/app/routes/patients.py
+++ b/app/routes/patients.py
@@ -1,4 +1,4 @@
-from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app, abort, send_file, send_from_directory
+from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app, abort, send_file, send_from_directory, make_response
 from flask_login import login_required, current_user
 from flask_wtf import FlaskForm
 from app import db
@@ -31,55 +31,103 @@ from wtforms import ValidationError
 # Import notification helpers
 from app.utils.notifications_helper import create_notification, create_notification_for_admins
 from app.models.patient_dietitian_assignment import PatientDietitianAssignment
+import io # Thêm import io
+import csv # Thêm import csv
+
+# --- Thêm import SHAP --- 
+import shap
+import json # Import json để lưu kết quả
+import joblib
 
 patients_bp = Blueprint('patients', __name__, url_prefix='/patients')
 
+_ml_components_cache = None
+
 # Tạo một class Form đơn giản để xử lý CSRF
 class EmptyForm(FlaskForm):
     pass
 
 # --- HÀM HELPER CHO DỰ ĐOÁN ML --- 
-def _run_ml_prediction(encounter):
-    """
-    Chạy dự đoán ML cho một encounter dựa trên phép đo mới nhất.
-
-    Args:
-        encounter: Đối tượng Encounter.
-
-    Returns:
-        bool: True nếu cần can thiệp, False nếu không cần, None nếu có lỗi
-    """
+def _load_ml_components():
+    """Loads ML model, imputer, scaler, and feature columns."""
     loaded_model = None
     loaded_imputer = None
     loaded_scaler = None
     loaded_feature_columns = None
-    
+    MODEL_DIR = os.path.join(current_app.root_path, '..', 'model', 'train_model')
+    MODEL_PATH = os.path.join(MODEL_DIR, 'referral_model.pkl')
+    IMPUTER_PATH = os.path.join(MODEL_DIR, 'imputer.pkl')
+    SCALER_PATH = os.path.join(MODEL_DIR, 'scaler.pkl')
+    FEATURES_PATH = os.path.join(MODEL_DIR, 'feature_columns.json')
+    SHAP_EXPLAINER_PATH = os.path.join(MODEL_DIR, 'shap_explainer.pkl') # Giữ lại đường dẫn nếu cần ở nơi khác
+
+    global _ml_components_cache
+    if _ml_components_cache:
+        return _ml_components_cache
+
     try:
-        MODEL_DIR = os.path.join(current_app.root_path, '..', 'model', 'train_model')
-        MODEL_PATH = os.path.join(MODEL_DIR, 'referral_model.pkl')
-        IMPUTER_PATH = os.path.join(MODEL_DIR, 'imputer.pkl')
-        SCALER_PATH = os.path.join(MODEL_DIR, 'scaler.pkl')
-        FEATURES_PATH = os.path.join(MODEL_DIR, 'feature_columns.pkl')
-
-        # Sử dụng context manager để đảm bảo file được đóng
-        with open(MODEL_PATH, 'rb') as f: loaded_model = pickle.load(f)
-        with open(IMPUTER_PATH, 'rb') as f: loaded_imputer = pickle.load(f)
-        with open(SCALER_PATH, 'rb') as f: loaded_scaler = pickle.load(f)
-        with open(FEATURES_PATH, 'rb') as f: loaded_feature_columns = pickle.load(f)
-        current_app.logger.info(f"ML components loaded successfully for prediction on encounter {encounter.encounterID}")
-
-    except FileNotFoundError as e:
-        error_msg = f"Lỗi tải file model: {e}."
-        current_app.logger.error(error_msg)
-        return None
+        # Tải các thành phần nếu chưa có trong cache
+        # *** SỬA: Di chuyển việc load vào trong try ***
+        if not os.path.exists(MODEL_PATH):
+            raise FileNotFoundError(f"Model file not found at {MODEL_PATH}")
+        if not os.path.exists(IMPUTER_PATH):
+            raise FileNotFoundError(f"Imputer file not found at {IMPUTER_PATH}")
+        if not os.path.exists(SCALER_PATH):
+            raise FileNotFoundError(f"Scaler file not found at {SCALER_PATH}")
+        if not os.path.exists(FEATURES_PATH):
+            raise FileNotFoundError(f"Feature columns file not found at {FEATURES_PATH}")
+            
+        with open(MODEL_PATH, 'rb') as f: loaded_model = joblib.load(f)
+        with open(IMPUTER_PATH, 'rb') as f: loaded_imputer = joblib.load(f)
+        with open(SCALER_PATH, 'rb') as f: loaded_scaler = joblib.load(f)
+        # *** SỬA: Đọc file JSON đúng cách ***
+        with open(FEATURES_PATH, 'r') as f: # Mở với chế độ 'r' (text)
+            loaded_feature_columns = json.load(f) # Dùng json.load()
+
+        _ml_components_cache = (loaded_model, loaded_imputer, loaded_scaler, loaded_feature_columns)
+        current_app.logger.info("Successfully loaded ML components.")
+        return _ml_components_cache
+        
+    except FileNotFoundError as fnf_error:
+        current_app.logger.error(f"Error loading ML component file: {fnf_error}")
+        # Có thể raise lại lỗi hoặc trả về None/Tuple các giá trị None
+        # raise # Raise lại lỗi để báo hiệu quá trình tải thất bại
+        return None, None, None, None # Trả về None để hàm gọi xử lý
+    except (pickle.UnpicklingError, EOFError, json.JSONDecodeError) as load_error:
+        current_app.logger.error(f"Error unpickling/decoding ML component: {load_error}")
+        # raise # Raise lại lỗi
+        return None, None, None, None # Trả về None
     except Exception as e:
-        error_msg = f"Lỗi không xác định khi tải model: {e}."
-        current_app.logger.error(error_msg, exc_info=True)
-        return None
+        current_app.logger.error(f"Unexpected error loading ML components: {e}", exc_info=True)
+        # raise # Raise lại lỗi
+        return None, None, None, None # Trả về None
+
+def _run_ml_prediction_with_details(encounter):
+    """
+    Runs ML prediction and calculates interpretability details (SHAP, Limits).
+
+    Args:
+        encounter: The Encounter object.
+
+    Returns:
+        dict: A dictionary containing:
+              'needs_intervention': bool or None (if error),
+              'top_features': list of top 3 influential feature names (or empty),
+              'limit_breaches': list of feature names breaching limits (or empty),
+              'error': str message if an error occurred, else None
+    """
+    prediction_result = {
+        'needs_intervention': None,
+        'top_features': [],
+        'limit_breaches': [],
+        'error': None
+    }
 
-    # Nếu model tải thành công, tiếp tục dự đoán
     try:
-        # Lấy phép đo MỚI NHẤT của encounter này
+        # Load ML components
+        loaded_model, loaded_imputer, loaded_scaler, loaded_feature_columns = _load_ml_components()
+
+        # Get latest measurement
         latest_measurement = PhysiologicalMeasurement.query.filter_by(
             encounter_id=encounter.encounterID
         ).order_by(
@@ -87,36 +135,172 @@ def _run_ml_prediction(encounter):
         ).first()
 
         if not latest_measurement:
-            current_app.logger.warning(f"No measurements found for encounter {encounter.encounterID}")
-            return None
+            prediction_result['error'] = f"No measurements found for encounter {encounter.encounterID}"
+            current_app.logger.warning(prediction_result['error'])
+            return prediction_result
 
-        # Chuyển thành DataFrame (chỉ một dòng)
+        # --- Prepare Data for Prediction --- 
         df_predict = pd.DataFrame([latest_measurement.to_dict()])
-
-        # Xử lý các cột bị thiếu và đảm bảo đúng thứ tự features
         missing_cols = set(loaded_feature_columns) - set(df_predict.columns)
         for c in missing_cols:
             df_predict[c] = np.nan
         df_predict = df_predict[loaded_feature_columns]
 
-        # Tiền xử lý: Impute và Scale
+        # Store original values before imputation for limit check
+        original_values = df_predict.iloc[0].to_dict()
+        
+        # Preprocess: Impute and Scale
         X_imputed = loaded_imputer.transform(df_predict)
         X_scaled = loaded_scaler.transform(X_imputed)
+        # Convert X_scaled back to DataFrame for SHAP (TreeExplainer needs DataFrame or numpy array with feature names)
+        # X_scaled_df = pd.DataFrame(X_scaled, columns=loaded_feature_columns) # Thử bỏ DataFrame
 
-        # Dự đoán (chỉ có 1 dự đoán vì đầu vào là 1 dòng)
-        prediction = loaded_model.predict(X_scaled)[0] # Lấy phần tử đầu tiên
+        # --- Prediction --- 
+        prediction = loaded_model.predict(X_scaled)[0]
         needs_intervention = bool(prediction == 1)
-
-        # Log kết quả dự đoán
+        prediction_result['needs_intervention'] = needs_intervention
         current_app.logger.info(f"ML prediction for encounter {encounter.encounterID}: needs_intervention={needs_intervention}")
         
-        return needs_intervention
+        # --- SHAP Calculation --- 
+        try:
+            # *** SỬA: Tạo explainer động thay vì load file ***
+            # Use TreeExplainer for RandomForest
+            explainer = shap.TreeExplainer(loaded_model)
+            # ************************************************
+            
+            # Calculate SHAP values for the single instance
+            shap_values_all_classes = explainer.shap_values(X_scaled) 
+            
+            # *** LOG CHI TIẾT VẪN GIỮ ***
+            shap_type = type(shap_values_all_classes)
+            shap_info = f"SHAP output type: {shap_type}. "
+            if isinstance(shap_values_all_classes, np.ndarray):
+                shap_info += f"Shape: {shap_values_all_classes.shape}. "
+            elif isinstance(shap_values_all_classes, list):
+                shap_info += f"Length: {len(shap_values_all_classes)}. "
+                if len(shap_values_all_classes) > 0:
+                    shap_info += f"First element type: {type(shap_values_all_classes[0])}. "
+                    if isinstance(shap_values_all_classes[0], np.ndarray):
+                         shap_info += f"First element shape: {shap_values_all_classes[0].shape}. "
+            current_app.logger.info(f"[SHAP Debug] Encounter {encounter.encounterID} - {shap_info}")
+            # *** KẾT THÚC LOG CHI TIẾT ***
+
+            # *** START: Xử lý SHAP values (Logic kiểm tra ndim==3, list, ndim==2 giữ nguyên) ***
+            shap_values_instance = None # Khởi tạo lại ở đây để rõ ràng
+            
+            # Trường hợp 1: SHAP là array 3D (1, n_features, 2) - cấu trúc mới
+            if isinstance(shap_values_all_classes, np.ndarray) and shap_values_all_classes.ndim == 3:
+                num_instances, num_features, num_classes = shap_values_all_classes.shape
+                if num_instances == 1 and num_classes == 2:
+                    # Lấy SHAP values cho lớp tích cực (index 1) và instance đầu tiên (index 0)
+                    shap_values_instance = shap_values_all_classes[0, :, 1]
+                    current_app.logger.info(f"Đã trích xuất SHAP values cho lớp tích cực (shape: {shap_values_instance.shape}) từ mảng 3D.")
+                else:
+                    current_app.logger.warning(f"Mảng SHAP có shape 3D không mong đợi cho encounter {encounter.encounterID}: {shap_values_all_classes.shape}")
+                    raise ValueError(f"Mảng SHAP 3D có shape không mong đợi: {shap_values_all_classes.shape}")
+            
+            # Trường hợp 2: SHAP là list các arrays [shap_class_0, shap_class_1] (cấu trúc cũ)
+            elif isinstance(shap_values_all_classes, list) and len(shap_values_all_classes) == 2:
+                shap_values_class1 = shap_values_all_classes[1]
+                if isinstance(shap_values_class1, np.ndarray):
+                    if shap_values_class1.ndim == 2 and shap_values_class1.shape[0] == 1:
+                        # Shape (1, num_features) - lấy mảng 1D
+                        shap_values_instance = shap_values_class1[0]
+                        current_app.logger.info(f"Đã trích xuất SHAP values cho lớp tích cực (shape: {shap_values_instance.shape}) từ đầu ra dạng list (2D).")
+                    elif shap_values_class1.ndim == 1:
+                        # Shape (num_features) - sử dụng trực tiếp
+                        shap_values_instance = shap_values_class1
+                        current_app.logger.info(f"Sử dụng SHAP values 1D cho lớp tích cực (shape: {shap_values_instance.shape}) từ đầu ra dạng list (1D).")
+                    else:
+                        current_app.logger.warning(f"Shape không mong đợi cho SHAP values lớp tích cực trong list cho encounter {encounter.encounterID}: {shap_values_class1.shape}")
+                        raise ValueError(f"Shape không mong đợi trong SHAP values dạng list: {shap_values_class1.shape}")
+                else:
+                    current_app.logger.warning(f"SHAP values lớp tích cực trong list không phải là mảng NumPy cho encounter {encounter.encounterID}.")
+                    raise ValueError("SHAP values lớp tích cực trong list không phải là mảng NumPy.")
+            
+            # Trường hợp 3: SHAP là array 2D (không thường gặp, nhưng vẫn xử lý)
+            elif isinstance(shap_values_all_classes, np.ndarray) and shap_values_all_classes.ndim == 2:
+                # Giả sử hàng đầu tiên chứa giá trị SHAP cho instance đang dự đoán
+                shap_values_instance = shap_values_all_classes[0]
+                current_app.logger.info(f"Sử dụng SHAP values từ mảng 2D (shape: {shap_values_instance.shape}) cho encounter {encounter.encounterID}.")
+            
+            # Trường hợp 4: Cấu trúc không mong đợi
+            else:
+                type_info = type(shap_values_all_classes)
+                structure_info = getattr(shap_values_all_classes, 'shape', len(shap_values_all_classes) if isinstance(shap_values_all_classes, list) else 'N/A')
+                current_app.logger.warning(f"Cấu trúc SHAP value không mong đợi cho encounter {encounter.encounterID}. Type: {type_info}, Structure: {structure_info}")
+                raise ValueError("Cấu trúc SHAP value không mong đợi")
+            
+            # Kiểm tra cuối cùng: Đảm bảo shap_values_instance là mảng 1D khớp với số lượng features
+            if shap_values_instance is None or not isinstance(shap_values_instance, np.ndarray) or shap_values_instance.ndim != 1:
+                current_app.logger.error(f"Không thể trích xuất mảng NumPy 1D hợp lệ cho SHAP values cho encounter {encounter.encounterID}. Kiểu cuối cùng: {type(shap_values_instance)}, Shape: {getattr(shap_values_instance, 'shape', 'N/A')}")
+                raise ValueError("Không thể trích xuất SHAP values 1D hợp lệ sau khi xử lý.")
+            
+            if len(shap_values_instance) != len(loaded_feature_columns):
+                raise ValueError(f"Không khớp giữa số lượng SHAP values ({len(shap_values_instance)}) và features ({len(loaded_feature_columns)}).")
+            # --- END: Xử lý SHAP values ---
+            
+            # Lưu SHAP values vào dict (feature: scalar_value)
+            prediction_result['shap_values'] = dict(zip(loaded_feature_columns, shap_values_instance))
+            
+            # Lấy top N features dựa trên giá trị tuyệt đối của SHAP
+            N_TOP_FEATURES = 5
+            sorted_features = sorted(prediction_result['shap_values'].items(), key=lambda item: abs(item[1]), reverse=True)
+            prediction_result['top_features'] = dict(sorted_features[:N_TOP_FEATURES])
+            current_app.logger.info(f"Top {N_TOP_FEATURES} features cho encounter {encounter.encounterID}: {prediction_result['top_features']}")
+            
+        except Exception as shap_error:
+            prediction_result['error'] = f"SHAP calculation failed: {shap_error}"
+            current_app.logger.error(f"SHAP calculation error for encounter {encounter.encounterID}: {shap_error}", exc_info=True)
+            # Continue without SHAP values
+
+        # --- Limit Breach Check --- 
+        try:
+            LIMITS_PATH = os.path.join(current_app.root_path, '..', 'model', 'data', 'physiological_limits.csv')
+            limits_df = pd.read_csv(LIMITS_PATH)
+            limits_df.set_index('Feature', inplace=True)
+            breaches = []
+            for feature, value in original_values.items():
+                if pd.isna(value) or feature not in limits_df.index:
+                    continue # Skip features with no value or no defined limits
+
+                limits = limits_df.loc[feature]
+                if needs_intervention: # Check against 'Referred' limits
+                    if value < limits['Referred_Min'] or value > limits['Referred_Max']:
+                        breaches.append(feature)
+                else: # Check against 'Not_Referred' limits
+                     if value < limits['Not_Referred_Min'] or value > limits['Not_Referred_Max']:
+                        breaches.append(feature)
+            prediction_result['limit_breaches'] = breaches
+            if breaches:
+                 current_app.logger.info(f"Limit breaches found for encounter {encounter.encounterID} (Prediction: {needs_intervention}): {breaches}")
+
+        except FileNotFoundError:
+            prediction_result['error'] = f"Limits file not found at {LIMITS_PATH}. Cannot check breaches."
+            current_app.logger.error(prediction_result['error'])
+            # Continue without limit check
+        except Exception as limit_error:
+            prediction_result['error'] = f"Limit check failed: {limit_error}"
+            current_app.logger.error(f"Limit check error for encounter {encounter.encounterID}: {limit_error}", exc_info=True)
+            # Continue without limit check
 
     except Exception as e:
-        error_msg = f"Lỗi trong quá trình dự đoán ML cho encounter {encounter.encounterID}: {e}"
-        current_app.logger.error(error_msg, exc_info=True)
-        return None
-# --- KẾT THÚC HÀM HELPER --- 
+        prediction_result['error'] = f"ML prediction process failed: {e}"
+        current_app.logger.error(f"Error in ML prediction process for encounter {encounter.encounterID}: {e}", exc_info=True)
+        prediction_result['needs_intervention'] = None # Ensure result is None on error
+
+    return prediction_result
+
+# --- HÀM HELPER CŨ (GIỮ LẠI HOẶC XÓA NẾU KHÔNG DÙNG) --- 
+def _run_ml_prediction(encounter):
+    # ... (Nội dung hàm cũ) ...
+    # CÓ THỂ XÓA HÀM NÀY VÀ THAY THẾ TẤT CẢ CÁC LẦN GỌI BẰNG HÀM MỚI
+    # Hoặc giữ lại để tương thích nếu có nơi khác gọi
+    # Để đơn giản, tạm thời chỉ log cảnh báo nếu hàm này được gọi
+    current_app.logger.warning("_run_ml_prediction (old version) called. Consider using _run_ml_prediction_with_details.")
+    # Gọi hàm mới để lấy kết quả chính
+    details = _run_ml_prediction_with_details(encounter)
+    return details['needs_intervention']
 
 def get_encounter_status_display(status: EncounterStatus):
     """Trả về text và màu sắc Bootstrap/Tailwind cho một EncounterStatus."""
@@ -264,31 +448,47 @@ def patient_detail(patient_id):
     
     latest_encounter = all_encounters[0] if all_encounters else None
 
-    # Chuẩn bị dữ liệu encounters để hiển thị, bao gồm status display mới
+    # Chuẩn bị dữ liệu encounters để hiển thị, bao gồm status display và kết quả ML
     encounters_data = []
     for enc_base in all_encounters: # Đổi tên biến lặp để tránh nhầm lẫn
-        # Thay vì refresh, query lại encounter để đảm bảo dữ liệu mới nhất
-        enc = Encounter.query.options(joinedload(Encounter.assigned_dietitian)).get(enc_base.encounterID)
+        # Query lại encounter để đảm bảo dữ liệu mới nhất và tải thông tin diễn giải
+        enc = Encounter.query.options(
+            joinedload(Encounter.assigned_dietitian)
+        ).get(enc_base.encounterID)
+        
         if not enc:
-            # Bỏ qua nếu encounter bị xóa giữa chừng (trường hợp hiếm)
             current_app.logger.warning(f"Encounter ID {enc_base.encounterID} not found during data preparation for patient detail view.")
             continue
             
-        # Sử dụng hàm helper đã cập nhật để lấy thông tin hiển thị status
         status_display = get_encounter_status_display(enc.status)
-        # TODO: Xác định các chỉ số bất ổn
-        # Logic này cần định nghĩa ngưỡng cho từng chỉ số
-        unstable_metrics = [] # Placeholder
+        
+        # Lấy và giải mã dữ liệu diễn giải ML từ JSON
+        top_features = []
+        limit_breaches = []
+        if enc.ml_needs_intervention is not None: # Chỉ lấy nếu ML đã chạy
+            if enc.ml_top_features_json:
+                try:
+                    top_features = json.loads(enc.ml_top_features_json)
+                except json.JSONDecodeError:
+                    current_app.logger.error(f"Failed to decode ml_top_features_json for encounter {enc.encounterID}")
+            if enc.ml_limit_breaches_json:
+                try:
+                    limit_breaches = json.loads(enc.ml_limit_breaches_json)
+                except json.JSONDecodeError:
+                    current_app.logger.error(f"Failed to decode ml_limit_breaches_json for encounter {enc.encounterID}")
+
+        # Tạo dict dữ liệu cho encounter này
         encounters_data.append({
             'encounter': enc, # Dùng encounter đã query lại
             'status': status_display,
-            'unstable_metrics': unstable_metrics
+            'top_features': top_features, # Thêm danh sách top features
+            'limit_breaches': limit_breaches[:3] # *** SỬA: Giới hạn chỉ lấy tối đa 3 phần tử ***
         })
-    
+
     # Debug log lại số lượng encounters với assigned_dietitian
     encounters_with_dietitian = sum(1 for data in encounters_data if data['encounter'].assigned_dietitian is not None)
     current_app.logger.info(f"Prepared {len(encounters_data)} encounters for display, {encounters_with_dietitian} with assigned dietitian")
-    
+
     # Lấy danh sách dietitian cho modal (nếu cần)
     dietitians = User.query.filter_by(role='Dietitian').options(
         joinedload(User.dietitian) # Sửa lại tên relationship từ dietitian_profile thành dietitian
@@ -1084,82 +1284,88 @@ def new_encounter(patient_id):
     patient = Patient.query.get_or_404(patient_id)
     form = EmptyForm()
     if form.validate_on_submit():
+        new_encounter_id = None # Khởi tạo để dùng trong except
         try: # Khối try chính
             # Check if there is an ON_GOING encounter
             latest_encounter = Encounter.query.filter_by(patient_id=patient.id).order_by(desc(Encounter.start_time)).first()
             if latest_encounter and latest_encounter.status == EncounterStatus.ON_GOING:
                 flash('Cannot create a new encounter while another is ongoing.', 'warning')
-                # Redirect immediately if ongoing encounter exists
-                return redirect(url_for('patients.patient_detail', patient_id=patient.id, _anchor='encounters'))
-
-            # --- Generate Custom Encounter ID --- 
-            # (Logic for generating ID remains the same)
-            patient_id_number = patient.id.split('-')[-1] if '-' in patient.id else patient.id
-            last_custom_encounter = Encounter.query.filter(
-                Encounter.patient_id == patient.id,
-                Encounter.custom_encounter_id.like(f'E-{patient_id_number}-%')
-            ).order_by(desc(Encounter.custom_encounter_id)).first()
-
-            next_seq = 1
-            if last_custom_encounter and last_custom_encounter.custom_encounter_id:
-                try: # Khối try nhỏ cho việc parse sequence
-                    last_seq_str = last_custom_encounter.custom_encounter_id.split('-')[-1]
-                    next_seq = int(last_seq_str) + 1
-                except (IndexError, ValueError):
-                    current_app.logger.warning(f"Could not parse sequence from last custom encounter ID '{last_custom_encounter.custom_encounter_id}'. Starting sequence at 1.")
-                    next_seq = 1
-            
-            new_custom_id = f"E-{patient_id_number}-{next_seq:02d}"
-            # --- End Generate Custom Encounter ID ---
+                # *** SỬA: Return ở cuối nếu không có lỗi ***
+                # return redirect(url_for('patients.patient_detail', patient_id=patient.id, _anchor='encounters'))
+            else:
+                # --- Generate Custom Encounter ID --- 
+                patient_id_number = patient.id.split('-')[-1] if '-' in patient.id else patient.id
+                last_custom_encounter = Encounter.query.filter(
+                    Encounter.patient_id == patient.id,
+                    Encounter.custom_encounter_id.like(f'E-{patient_id_number}-%')
+                ).order_by(desc(Encounter.custom_encounter_id)).first()
+
+                next_seq = 1
+                if last_custom_encounter and last_custom_encounter.custom_encounter_id:
+                    try: # Khối try nhỏ cho việc parse sequence
+                        last_seq_str = last_custom_encounter.custom_encounter_id.split('-')[-1]
+                        next_seq = int(last_seq_str) + 1
+                    except (IndexError, ValueError):
+                        current_app.logger.warning(f"Could not parse sequence from last custom encounter ID '{last_custom_encounter.custom_encounter_id}'. Starting sequence at 1.")
+                        next_seq = 1
+                
+                new_custom_id = f"E-{patient_id_number}-{next_seq:02d}"
+                # --- End Generate Custom Encounter ID ---
 
-            # Create new Encounter with custom ID
-            new_encounter = Encounter(
-                patient_id=patient.id,
-                start_time=datetime.utcnow(),
-                custom_encounter_id=new_custom_id
-            )
-            db.session.add(new_encounter)
-            db.session.flush() # Get encounterID for logs/links
+                # Create new Encounter with custom ID
+                new_encounter = Encounter(
+                    patient_id=patient.id,
+                    start_time=datetime.utcnow(),
+                    custom_encounter_id=new_custom_id
+                )
+                db.session.add(new_encounter)
+                db.session.flush() # Get encounterID for logs/links
+                new_encounter_id = new_encounter.encounterID # Lưu ID để dùng trong redirect
 
-            # Log activity with custom ID
-            activity = ActivityLog(
-                user_id=current_user.userID,
-                action=f"Created new encounter {new_custom_id} (ID: {new_encounter.encounterID}) for patient {patient.full_name}",
-                details=f"Patient ID: {patient.id}, Encounter ID: {new_encounter.encounterID}, Custom ID: {new_custom_id}"
-            )
-            db.session.add(activity)
+                # Log activity with custom ID
+                activity = ActivityLog(
+                    user_id=current_user.userID,
+                    action=f"Created new encounter {new_custom_id} (ID: {new_encounter_id}) for patient {patient.full_name}",
+                    details=f"Patient ID: {patient.id}, Encounter ID: {new_encounter_id}, Custom ID: {new_custom_id}"
+                )
+                db.session.add(activity)
 
-            # Update patient status
-            update_patient_status_from_encounters(patient)
+                # Update patient status
+                update_patient_status_from_encounters(patient)
 
-            # Add Notification for New Encounter
-            encounter_display_id = new_encounter.custom_encounter_id
-            admin_message = f"New encounter ({encounter_display_id}) created for patient {patient.full_name} by {current_user.full_name}."
-            link_kwargs = {'patient_id': patient.id, 'encounter_id': new_encounter.encounterID}
-            admin_link = ('patients.encounter_measurements', link_kwargs)
-            create_notification_for_admins(admin_message, admin_link, exclude_user_id=current_user.userID)
-            
-            # Notify assigned dietitian
-            if patient.assigned_dietitian_user_id and patient.assigned_dietitian_user_id != current_user.userID:
-                dietitian_message = f"New encounter ({encounter_display_id}) created for your patient {patient.full_name}."
-                create_notification(patient.assigned_dietitian_user_id, dietitian_message, admin_link)
-
-            # Commit everything at the end of the try block
-            db.session.commit() 
-            flash('New encounter created successfully.', 'success') 
-            # Redirect after successful commit inside try
-            return redirect(url_for('patients.encounter_measurements', patient_id=patient.id, encounter_id=new_encounter.encounterID))
-
-        except Exception as e: # Khối except tương ứng, cùng mức thụt lề với try
-            db.session.rollback() 
+                # Add Notification for New Encounter
+                encounter_display_id = new_encounter.custom_encounter_id
+                admin_message = f"New encounter ({encounter_display_id}) created for patient {patient.full_name} by {current_user.full_name}."
+                link_kwargs = {'patient_id': patient.id, 'encounter_id': new_encounter_id}
+                admin_link = ('patients.encounter_measurements', link_kwargs)
+                create_notification_for_admins(admin_message, admin_link, exclude_user_id=current_user.userID)
+                
+                # Notify assigned dietitian
+                if patient.assigned_dietitian_user_id and patient.assigned_dietitian_user_id != current_user.userID:
+                    dietitian_message = f"New encounter ({encounter_display_id}) created for your patient {patient.full_name}."
+                    create_notification(patient.assigned_dietitian_user_id, dietitian_message, admin_link)
+
+                # Commit everything at the end of the try block
+                db.session.commit()
+                flash('New encounter created successfully.', 'success') 
+                # Redirect after successful commit inside try
+                return redirect(url_for('patients.encounter_measurements', patient_id=patient.id, encounter_id=new_encounter_id))
+
+        # *** SỬA: Khối except phải cùng mức thụt lề với try ***
+        except Exception as e: 
+            db.session.rollback()
             current_app.logger.error(f"Error creating new encounter for patient {patient.id}: {str(e)}", exc_info=True) 
             flash(f'Error creating encounter: {str(e)}', 'danger') 
-            # Redirect on error
-            return redirect(url_for('patients.patient_detail', patient_id=patient.id))
+            # *** SỬA: Redirect về patient detail nếu có lỗi ***
+            return redirect(url_for('patients.patient_detail', patient_id=patient.id, _anchor='encounters'))
+        
+        # *** SỬA: Redirect về patient detail nếu không tạo mới (do đang ongoing) ***
+        return redirect(url_for('patients.patient_detail', patient_id=patient.id, _anchor='encounters')) 
+
     else:
         # Handle CSRF or other form validation errors
         flash('Invalid request to create encounter.', 'danger')
-        return redirect(url_for('patients.patient_detail', patient_id=patient.id))
+        return redirect(url_for('patients.patient_detail', patient_id=patient.id, _anchor='encounters'))
 
 def update_patient_status_from_encounters(patient):
     """Update patient status based on encounters and referrals. Also handles status change notifications."""
@@ -1183,15 +1389,15 @@ def update_patient_status_from_encounters(patient):
             latest_referral = Referral.query.filter_by(encounter_id=latest_encounter.encounterID).order_by(desc(Referral.referralRequestedDateTime)).first()
             if latest_referral:
                 if latest_referral.referral_status == ReferralStatus.DIETITIAN_UNASSIGNED:
-                    new_status = PatientStatus.NEEDS_ASSESSMENT
+                    new_status = PatientStatus.NEEDS_ASSESSMENT # Thụt vào đúng
                 elif latest_referral.referral_status == ReferralStatus.WAITING_FOR_REPORT:
-                    new_status = PatientStatus.ASSESSMENT_IN_PROGRESS
+                    new_status = PatientStatus.ASSESSMENT_IN_PROGRESS # Thụt vào đúng
                 elif latest_referral.referral_status == ReferralStatus.COMPLETED:
                     # Encounter đang diễn ra nhưng referral đã xong? -> Vẫn là IN_PROGRESS
-                    new_status = PatientStatus.ASSESSMENT_IN_PROGRESS
-            else:
+                    new_status = PatientStatus.ASSESSMENT_IN_PROGRESS # Thụt vào đúng
+            else: # Khối else này phải cùng cấp với if latest_referral:
                 # Encounter đang diễn ra nhưng không có referral -> ASSESSMENT_IN_PROGRESS
-                new_status = PatientStatus.ASSESSMENT_IN_PROGRESS 
+                new_status = PatientStatus.ASSESSMENT_IN_PROGRESS # Thụt vào đúng
 
     # So sánh và cập nhật trạng thái nếu có thay đổi
     old_status = patient.status
@@ -1216,7 +1422,7 @@ def update_patient_status_from_encounters(patient):
 
     return new_status # Return the new (or unchanged) status
 
-@patients_bp.route('/<string:patient_id>/encounters/<int:encounter_pk>/delete', methods=['POST'])
+@patients_bp.route('/<string:patient_id>/encounter/<int:encounter_pk>/delete', methods=['POST'])
 @login_required
 def delete_encounter(patient_id, encounter_pk):
     form = EmptyForm() 
@@ -1312,17 +1518,35 @@ def run_encounter_ml(patient_id, encounter_id):
                 return redirect(url_for('patients.encounter_measurements', patient_id=patient_id, encounter_id=encounter_id)) # Sửa lỗi thụt lề
 
             # Now you can safely access encounter
-            original_encounter_status = encounter.status # Sửa lỗi thụt lề
             encounter.status = EncounterStatus.ON_GOING # Set to ON_GOING when ML runs # Sửa lỗi thụt lề
-            needs_intervention_result = _run_ml_prediction(encounter) # Sửa lỗi thụt lề
             
-            if needs_intervention_result is not None: # Sửa lỗi thụt lề
-                encounter.needs_intervention = needs_intervention_result # Sửa lỗi thụt lề
+            # *** GỌI HÀM MỚI ĐỂ LẤY KẾT QUẢ VÀ CHI TIẾT ***
+            prediction_details = _run_ml_prediction_with_details(encounter) # Sửa lỗi thụt lề
+            needs_intervention_result = prediction_details['needs_intervention'] # Sửa lỗi thụt lề
+            top_features = prediction_details['top_features'] # Sửa lỗi thụt lề
+            limit_breaches = prediction_details['limit_breaches'] # Sửa lỗi thụt lề
+            prediction_error = prediction_details['error'] # Sửa lỗi thụt lề
+            
+            # Lưu thời gian chạy ML
+            encounter.ml_prediction_time = datetime.utcnow() # Sửa lỗi thụt lề
+            
+            # Xử lý lỗi từ hàm dự đoán
+            if prediction_error: # Sửa lỗi thụt lề
+                flash(f"ML Prediction Warning/Error: {prediction_error}", "warning") # Sửa lỗi thụt lề
+                # Vẫn tiếp tục để lưu kết quả (có thể là None)
+            
+            if needs_intervention_result is not None: # Chỉ lưu kết quả nếu ML chạy không lỗi cơ bản # Sửa lỗi thụt lề
+                encounter.ml_needs_intervention = needs_intervention_result # Sửa lỗi thụt lề
+                # Lưu kết quả diễn giải dưới dạng JSON string
+                encounter.ml_top_features_json = json.dumps(top_features) if top_features else None # Sửa lỗi thụt lề
+                encounter.ml_limit_breaches_json = json.dumps(limit_breaches) if limit_breaches else None # Sửa lỗi thụt lề
+                
                 referral_created_by_ml = False # Sửa lỗi thụt lề
 
                 if needs_intervention_result: # Sửa lỗi thụt lề
                     existing_referral = Referral.query.filter_by(patient_id=patient.id, encounter_id=encounter.encounterID).first() # Sửa lỗi thụt lề
                     if not existing_referral: # Sửa lỗi thụt lề
+                        # ... (Logic tạo Referral và Report giữ nguyên) ...
                         # Need to define referral details properly here
                         new_referral = Referral( # Sửa lỗi thụt lề
                             patient_id=patient.id, # Sửa lỗi thụt lề
@@ -1370,6 +1594,7 @@ def run_encounter_ml(patient_id, encounter_id):
                         # *** END AUTO-CREATE REPORT LOGIC ***
 
                         # Create Notifications for Referral
+                        # ... (Logic thông báo giữ nguyên) ...
                         admin_users = User.query.filter_by(role='admin').all() # Sửa lỗi thụt lề
                         admin_ids = [admin.userID for admin in admin_users] # Sửa lỗi thụt lề
                         # Tạo thông báo cho tất cả admin ngoại trừ người dùng hiện tại nếu họ là admin
@@ -1387,7 +1612,7 @@ def run_encounter_ml(patient_id, encounter_id):
                         flash("ML prediction indicates intervention needed. An existing referral is already in place.", "info") # Sửa lỗi thụt lề
                 else: # Khi không cần can thiệp # Sửa lỗi thụt lề
                     flash("ML prediction indicates no intervention needed at this time.", "success") # Sửa lỗi thụt lề
-
+                    # ... (Logic cập nhật status Encounter và Referral giữ nguyên) ...
                     # Luôn cập nhật encounter thành FINISHED nếu nó chưa finished
                     if encounter.status != EncounterStatus.FINISHED: # Sửa lỗi thụt lề
                         encounter.status = EncounterStatus.FINISHED # Sửa lỗi thụt lề
@@ -1413,6 +1638,7 @@ def run_encounter_ml(patient_id, encounter_id):
 
                 # Cập nhật trạng thái bệnh nhân (sẽ tự xử lý logic COMPLETED dựa trên encounter FINISHED)
                 update_patient_status_from_encounters(patient) # Sửa lỗi thụt lề
+                db.session.add(encounter) # *** QUAN TRỌNG: Đảm bảo encounter được add vào session trước khi commit ***
                 db.session.commit() # Commit tất cả thay đổi (encounter, referral, report, patient status) # Sửa lỗi thụt lề
             
             else: # needs_intervention_result is None (lỗi ML) # Sửa lỗi thụt lề
@@ -1423,12 +1649,13 @@ def run_encounter_ml(patient_id, encounter_id):
             return redirect(url_for('patients.encounter_measurements', patient_id=patient_id, encounter_id=encounter_id)) # Sửa lỗi thụt lề
     
         except Exception as e: # Sửa lỗi thụt lề
-            # ... (khối except) ...
+            # ... (khối except giữ nguyên) ...
             db.session.rollback() # Rollback if any error occurs during the process # Sửa lỗi thụt lề
             current_app.logger.error(f"Error running ML for encounter {encounter_id}: {str(e)}", exc_info=True) # Sửa lỗi thụt lề
             flash(f"Error running ML prediction: {str(e)}", "danger") # Sửa lỗi thụt lề
             return redirect(url_for('patients.encounter_measurements', patient_id=patient_id, encounter_id=encounter_id)) # Sửa lỗi thụt lề
     else:
+        # ... (Xử lý lỗi CSRF giữ nguyên) ...
         # Handle CSRF error
         flash('Invalid request to run ML prediction.', 'danger') # Sửa lỗi thụt lề
         return redirect(url_for('patients.encounter_measurements', patient_id=patient_id, encounter_id=encounter_id)) # Sửa lỗi thụt lề
@@ -1708,3 +1935,244 @@ def bulk_auto_assign():
 
     return redirect(url_for('patients.index'))
 # --- END NEW ROUTE ---
+
+# --- HÀM HELPER MỚI ĐỂ LẤY CHI TIẾT DỰ ĐOÁN ML ---
+def _get_ml_prediction_details(encounter, measurement):
+    """
+    Hàm riêng để lấy chi tiết dự đoán ML cho encounter/measurement.
+    """
+    prediction_details = {
+        'needs_intervention': None,
+        'probability': None,
+        'shap_values': {},
+        'top_features': {},
+        'error': '' # *** SỬA: Khởi tạo bằng chuỗi rỗng ***
+    }
+    
+    if not measurement:
+        prediction_details['error'] = "No measurement data available for this encounter"
+        return prediction_details
+    
+    # *** SỬA: Khối try chính bao quanh toàn bộ quá trình ***
+    try: 
+        loaded_model, loaded_imputer, loaded_scaler, loaded_feature_columns = _load_ml_components()
+        # *** THÊM KIỂM TRA: Nếu không load được components thì báo lỗi ***
+        if not all([loaded_model, loaded_imputer, loaded_scaler, loaded_feature_columns]):
+            raise ValueError("Failed to load one or more ML components. Check previous logs.")
+
+        features_dict = measurement.to_dict()
+        df_predict = pd.DataFrame([features_dict])
+        
+        # ... (Chuẩn bị df_predict, impute, scale) ...
+        df_predict = df_predict[[col for col in loaded_feature_columns if col in df_predict.columns]]
+        missing_features = [col for col in loaded_feature_columns if col not in df_predict.columns]
+        if missing_features:
+            current_app.logger.warning(f"(Download Context) Missing features for ML prediction: {missing_features}")
+            for col in missing_features:
+                df_predict[col] = np.nan
+        df_predict = df_predict[loaded_feature_columns]
+        X_imputed = loaded_imputer.transform(df_predict)
+        X_scaled = loaded_scaler.transform(X_imputed)
+        
+        # Dự đoán xác suất
+        prob = loaded_model.predict_proba(X_scaled)[0, 1]  
+        prediction_details['probability'] = float(prob)
+        prediction_details['needs_intervention'] = bool(prob >= 0.5)
+        
+        # --- Tạo và sử dụng SHAP explainer --- 
+        # *** SỬA: Khối try nhỏ hơn chỉ bao quanh phần SHAP ***
+        try: 
+            explainer = shap.TreeExplainer(loaded_model)
+            # *** SỬA: Không cần kiểm tra if explainer nữa vì TreeExplainer luôn trả về object hoặc lỗi ***
+            # if explainer:
+            shap_values_all_classes = explainer.shap_values(X_scaled)
+            current_app.logger.info(f"[SHAP Debug] (Download Context) Encounter {encounter.encounterID} - SHAP output type: {type(shap_values_all_classes)}. Shape: {getattr(shap_values_all_classes, 'shape', 'N/A')}.")
+            
+            # --- START: Xử lý SHAP values --- 
+            shap_values_instance = None
+            # Trường hợp 1: SHAP là array 3D (1, n_features, 2)
+            if isinstance(shap_values_all_classes, np.ndarray) and shap_values_all_classes.ndim == 3:
+                # ... (logic xử lý mảng 3D giữ nguyên) ...
+                shap_values_instance = shap_values_all_classes[0, :, 1]
+                current_app.logger.info(f"(Download Context) Extracted SHAP values ... from 3D array.") 
+            # Trường hợp 2: SHAP là list các arrays [shap_class_0, shap_class_1]
+            elif isinstance(shap_values_all_classes, list) and len(shap_values_all_classes) == 2:
+                # ... (logic xử lý list giữ nguyên) ...
+                shap_values_class1 = shap_values_all_classes[1]
+                if isinstance(shap_values_class1, np.ndarray):
+                     if shap_values_class1.ndim == 2 and shap_values_class1.shape[0] == 1:
+                         shap_values_instance = shap_values_class1[0] 
+                         current_app.logger.info(f"(Download Context) Extracted SHAP values ... from list output (2D).")
+                     elif shap_values_class1.ndim == 1:
+                         shap_values_instance = shap_values_class1 
+                         current_app.logger.info(f"(Download Context) Using 1D SHAP values ... from list output (1D).")
+                     else:
+                         # *** SỬA: Báo lỗi đúng ***
+                         raise ValueError(f"(Download Context) Unexpected shape {shap_values_class1.shape} in list-based SHAP values...")
+                else:
+                     # *** SỬA: Báo lỗi đúng ***
+                     raise ValueError("(Download Context) Positive class SHAP values in list are not a NumPy array.")
+            # Trường hợp 3: SHAP là array 2D
+            elif isinstance(shap_values_all_classes, np.ndarray) and shap_values_all_classes.ndim == 2:
+                # ... (logic xử lý mảng 2D giữ nguyên) ...
+                shap_values_instance = shap_values_all_classes[0]
+                current_app.logger.info(f"(Download Context) Using SHAP values from 2D array (shape: {shap_values_instance.shape})...")
+            # Trường hợp 4: Cấu trúc không mong đợi
+            else:
+                 # *** SỬA: Báo lỗi đúng ***
+                 type_info = type(shap_values_all_classes)
+                 structure_info = getattr(shap_values_all_classes, 'shape', len(shap_values_all_classes) if isinstance(shap_values_all_classes, list) else 'N/A')
+                 raise ValueError(f"(Download Context) Unexpected SHAP value structure. Type: {type_info}, Structure: {structure_info}")
+            
+            # Kiểm tra cuối cùng
+            if shap_values_instance is None:
+                 # *** SỬA: Báo lỗi đúng ***
+                 raise ValueError("(Download Context) Failed to extract SHAP values from any known structure.")
+            if not isinstance(shap_values_instance, np.ndarray) or shap_values_instance.ndim != 1:
+                 # *** SỬA: Báo lỗi đúng ***
+                 raise ValueError(f"(Download Context) Extracted SHAP values are not a 1D NumPy array. Shape: {getattr(shap_values_instance, 'shape', 'N/A')}")
+            if len(shap_values_instance) != len(loaded_feature_columns):
+                 # *** SỬA: Báo lỗi đúng ***
+                 raise ValueError(f"(Download Context) Mismatch between number of SHAP values ({len(shap_values_instance)}) and features ({len(loaded_feature_columns)}).")
+            # --- END: Xử lý SHAP values ---
+            
+            # Lưu SHAP values và top features
+            prediction_details['shap_values'] = dict(zip(loaded_feature_columns, shap_values_instance))
+            N_TOP_FEATURES = 5
+            # *** SỬA: Tính abs() an toàn hơn ***
+            abs_shap_dict = {k: abs(v) if isinstance(v, (int, float, np.number)) else 0 for k, v in prediction_details['shap_values'].items()}
+            sorted_features = sorted(abs_shap_dict.items(), key=lambda item: item[1], reverse=True)
+            prediction_details['top_features'] = dict(sorted_features[:N_TOP_FEATURES])
+            current_app.logger.info(f"(Download Context) Top {N_TOP_FEATURES} features cho encounter {encounter.encounterID}: {prediction_details['top_features']}")
+        
+        # *** SỬA: Khối except tương ứng với try của SHAP ***
+        except Exception as shap_e: 
+            error_msg = f"Failed to get SHAP details: {shap_e}"
+            # *** SỬA: Nối lỗi đúng cách ***
+            prediction_details['error'] = prediction_details['error'] + f"; {error_msg}" if prediction_details['error'] else error_msg
+            current_app.logger.error(f"Error getting SHAP details for download (Encounter {encounter.encounterID}): {shap_e}", exc_info=True)
+            prediction_details['shap_values'] = {}
+            prediction_details['top_features'] = {}
+            
+    # *** SỬA: Khối except tương ứng với try chính ***
+    except Exception as e:
+        error_msg = f"Failed to get ML prediction details: {e}"
+        # *** SỬA: Nối lỗi đúng cách ***
+        prediction_details['error'] = prediction_details['error'] + f"; {error_msg}" if prediction_details['error'] else error_msg
+        current_app.logger.error(f"Error getting ML prediction details (Encounter {encounter.encounterID}): {e}", exc_info=True)
+        prediction_details['probability'] = None
+        prediction_details['needs_intervention'] = None
+        prediction_details['shap_values'] = {}
+        prediction_details['top_features'] = {}
+    
+    return prediction_details
+
+# --- ROUTE MỚI ĐỂ TẢI KẾT QUẢ ML (Cập nhật) ---
+@patients_bp.route('/<string:patient_id>/encounter/<int:encounter_id>/download_ml_results')
+@login_required
+def download_ml_results(patient_id, encounter_id):
+    # ... (Lấy encounter, latest_measurement) ...
+    encounter = Encounter.query.options(
+        joinedload(Encounter.patient) # Tải sẵn thông tin bệnh nhân
+    ).filter_by(
+        patient_id=patient_id,
+        encounterID=encounter_id
+    ).first_or_404()
+
+    if encounter.ml_needs_intervention is None: # Sử dụng trường mới
+        flash('ML prediction has not been run for this encounter yet.', 'warning')
+        return redirect(url_for('.encounter_measurements', patient_id=patient_id, encounter_id=encounter_id))
+
+    latest_measurement = PhysiologicalMeasurement.query.filter_by(
+        encounter_id=encounter.encounterID
+    ).order_by(
+        desc(PhysiologicalMeasurement.measurementDateTime)
+    ).first()
+
+    if not latest_measurement:
+        flash('Could not find the measurement used for the ML prediction.', 'error')
+        return redirect(url_for('.encounter_measurements', patient_id=patient_id, encounter_id=encounter_id))
+
+    # Lấy chi tiết dự đoán, bao gồm shap_values
+    prediction_details = _get_ml_prediction_details(encounter, latest_measurement)
+
+    if not prediction_details or prediction_details.get('error'):
+        flash(f"Could not retrieve ML prediction details: {prediction_details.get('error', 'Unknown error')}", 'error')
+        return redirect(url_for('.encounter_measurements', patient_id=patient_id, encounter_id=encounter_id))
+
+    output = io.StringIO()
+    writer = csv.writer(output)
+
+    # --- Header --- 
+    writer.writerow(['Patient ID', encounter.patient_id])
+    writer.writerow(['Patient Name', encounter.patient.full_name])
+    writer.writerow(['Encounter ID', encounter.custom_encounter_id or encounter.encounterID])
+    writer.writerow(['Measurement Time', latest_measurement.measurementDateTime.strftime('%Y-%m-%d %H:%M:%S') if latest_measurement.measurementDateTime else 'N/A'])
+    writer.writerow(['Prediction Result', 'Needs Intervention' if prediction_details['needs_intervention'] else 'No Intervention Needed'])
+    writer.writerow([])
+    writer.writerow(['Feature', 'Value', 'SHAP Value (Impact on Prediction)'])
+
+    # --- Feature Details --- 
+    shap_values = prediction_details.get('shap_values', {}) 
+    
+    # *** SỬA: Lấy feature names từ shap_values keys ***
+    feature_names = list(shap_values.keys())
+
+    # Hàm helper để lấy giá trị tuyệt đối của SHAP
+    def get_abs_shap(feature_name):
+        val = shap_values.get(feature_name, 0)
+        if isinstance(val, (int, float, np.number)): 
+            return abs(val)
+        return 0
+
+    # *** SỬA: Sắp xếp feature_names dựa trên get_abs_shap ***
+    sorted_feature_names = sorted(feature_names, key=get_abs_shap, reverse=True)
+
+    # *** SỬA: Lặp qua sorted_feature_names và lấy value từ latest_measurement ***
+    for feature in sorted_feature_names:
+        # Lấy giá trị thực tế từ measurement object
+        value = getattr(latest_measurement, feature, None) 
+        shap_value = shap_values.get(feature)
+        
+        # Định dạng SHAP value
+        shap_display = 'N/A'
+        if isinstance(shap_value, (float, np.float64)):
+             shap_display = f'{shap_value:.4f}'
+        elif isinstance(shap_value, (int, np.integer)):
+             shap_display = str(shap_value)
+        elif shap_value is not None:
+             shap_display = str(shap_value)
+
+        # Định dạng Value
+        value_display = 'N/A'
+        if value is not None:
+             if isinstance(value, (float, np.float64)):
+                 value_display = f'{value:.2f}'
+             else:
+                 value_display = str(value)
+
+        writer.writerow([
+            feature,
+            value_display, # Sử dụng giá trị đã định dạng
+            shap_display # Sử dụng giá trị đã định dạng
+        ])
+
+    output.seek(0)
+    response = make_response(output.getvalue())
+    response.headers["Content-Disposition"] = f"attachment; filename=ml_results_enc_{encounter.custom_encounter_id or encounter.encounterID}_pat_{patient_id}.csv"
+    response.headers["Content-type"] = "text/csv"
+
+    # Log download activity
+    try:
+        log_entry = ActivityLog(
+            user_id=current_user.userID,
+            action=f'Downloaded ML results for Encounter {encounter.custom_encounter_id or encounter.encounterID} (Patient {patient_id})'
+        )
+        db.session.add(log_entry)
+        db.session.commit()
+    except Exception as e:
+        db.session.rollback()
+        current_app.logger.error(f"Error logging ML results download: {e}")
+
+    return response
+# --- KẾT THÚC ROUTE TẢI KẾT QUẢ ML ---
diff --git a/app/templates/base.html b/app/templates/base.html
index f681991..a4055fb 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -341,7 +341,7 @@
                     {# Support #}
                     {% if current_user.role in ['Admin', 'Dietitian'] %}
                     <li class="nav-item relative {% if request.endpoint == 'support.index' %}active{% endif %}">
-                        <a href="{{ url_for('support.index') }}">
+                        <a href="{{ url_for('support.index') }}#chat-input-section">
                             <i class="fas fa-headset"></i>
                             <span class="nav-text">Support</span>
                             {# Điều chỉnh vị trí để badge không che lấp icon #}
@@ -500,7 +500,7 @@
     <!-- Footer -->
     <footer class="bg-white py-6 mt-12">
         <div class="container mx-auto px-4">
-            <p class="text-center text-gray-500 text-sm">&copy; {{ current_year|default(2023) }} CCU HTM - Intensive Care Unit Nutrition Management System</p>
+            <p class="text-center text-gray-500 text-sm">&copy; {{ current_year|default(2023) }} CCU HTM - Critical Care Unit by HuanTrungMing</p>
         </div>
     </footer>
 
diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html
index 810df42..8db75cd 100644
--- a/app/templates/dashboard.html
+++ b/app/templates/dashboard.html
@@ -7,10 +7,10 @@
 {% block content %}
 <div class="py-6">
     <!-- Statistics Overview -->
-    <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
-        <!-- Total Patients -->
-        <div class="card bg-white overflow-hidden shadow-lg rounded-lg border-l-4 border-blue-500 animate-fade-in" style="animation-delay: 0.1s">
-            <div class="px-4 py-5 sm:p-6">
+    <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
+        <!-- Total Patients - ĐÃ THAY ĐỔI: Chỉ chiếm 1 cột, không còn row-span, bỏ canvas -->
+        <div class="card bg-white overflow-hidden shadow-lg rounded-lg border-l-4 border-blue-500 animate-fade-in flex flex-col" style="animation-delay: 0.1s">
+            <div class="px-4 py-5 sm:p-6 flex-grow">
                 <div class="flex items-center">
                     <div class="flex-shrink-0 bg-blue-100 rounded-full p-3">
                         <i class="fas fa-user-injured text-blue-600 text-xl"></i>
@@ -24,17 +24,18 @@
                                 <div class="text-2xl font-semibold text-gray-900">
                                     {{ stats.total_patients }}
                                 </div>
-                                <div class="ml-2 flex items-baseline text-sm font-semibold text-green-600">
-                                    <i class="fas fa-arrow-up"></i>
-                                    <span class="sr-only">Increased by</span>
-                                    4.5%
+                                <div class="ml-2 flex items-baseline text-sm font-semibold {{ 'text-green-600' if stats.patients_percent_change > 0 else 'text-red-600' if stats.patients_percent_change < 0 else 'text-gray-600' }}">
+                                    <i class="{{ 'fas fa-arrow-up' if stats.patients_percent_change > 0 else 'fas fa-arrow-down' if stats.patients_percent_change < 0 else 'fas fa-minus' }}"></i>
+                                    <span class="sr-only">{{ 'Increased by' if stats.patients_percent_change > 0 else 'Decreased by' if stats.patients_percent_change < 0 else 'No change' }}</span>
+                                    {{ '%.1f'|format(stats.patients_percent_change|abs) }}%
                                 </div>
                             </dd>
                         </dl>
                     </div>
                 </div>
+                <!-- ĐÃ XÓA: Biểu đồ mini trong thẻ Total Patients -->
             </div>
-            <div class="bg-gradient-to-r from-blue-50 to-blue-100 px-4 py-4 sm:px-6">
+            <div class="bg-gradient-to-r from-blue-50 to-blue-100 px-4 py-4 sm:px-6 mt-auto">
                 <div class="text-sm">
                     <a href="{{ url_for('patients.index') }}" class="font-medium text-blue-600 hover:text-blue-500 transition-colors duration-150">
                         View all patients <span class="ml-1">&rarr;</span>
@@ -43,105 +44,70 @@
             </div>
         </div>
 
-        <!-- New Referrals -->
-        <div class="card bg-white overflow-hidden shadow-lg rounded-lg border-l-4 border-green-500 animate-fade-in" style="animation-delay: 0.2s">
+        <!-- Today's Referrals - ĐÃ THAY ĐỔI: Màu vàng -->
+        <div class="card bg-white overflow-hidden shadow-lg rounded-lg border-l-4 border-yellow-500 animate-fade-in" style="animation-delay: 0.2s">
             <div class="px-4 py-5 sm:p-6">
                 <div class="flex items-center">
-                    <div class="flex-shrink-0 bg-green-100 rounded-full p-3">
-                        <i class="fas fa-notes-medical text-green-600 text-xl"></i>
+                    <div class="flex-shrink-0 bg-yellow-100 rounded-full p-3">
+                        <i class="fas fa-notes-medical text-yellow-600 text-xl"></i>
                     </div>
                     <div class="ml-5 w-0 flex-1">
                         <dl>
                             <dt class="text-sm font-medium text-gray-500 truncate">
-                                New Referrals
+                                Today's Referrals
                             </dt>
                             <dd class="flex items-baseline">
                                 <div class="text-2xl font-semibold text-gray-900">
-                                    {{ stats.new_referrals }}
+                                    {{ stats.todays_referrals }}
                                 </div>
-                                <div class="ml-2 flex items-baseline text-sm font-semibold text-green-600">
-                                    <i class="fas fa-arrow-up"></i>
-                                    <span class="sr-only">Increased by</span>
-                                    12%
+                                <div class="ml-2 flex items-baseline text-sm font-semibold {{ 'text-green-600' if stats.referrals_percent_change > 0 else 'text-red-600' if stats.referrals_percent_change < 0 else 'text-gray-600' }}">
+                                    <i class="{{ 'fas fa-arrow-up' if stats.referrals_percent_change > 0 else 'fas fa-arrow-down' if stats.referrals_percent_change < 0 else 'fas fa-minus' }}"></i>
+                                    <span class="sr-only">{{ 'Increased by' if stats.referrals_percent_change > 0 else 'Decreased by' if stats.referrals_percent_change < 0 else 'No change' }}</span>
+                                    {{ '%.1f'|format(stats.referrals_percent_change|abs) }}%
                                 </div>
                             </dd>
                         </dl>
                     </div>
                 </div>
             </div>
-            <div class="bg-gradient-to-r from-green-50 to-green-100 px-4 py-4 sm:px-6">
+            <div class="bg-gradient-to-r from-yellow-50 to-yellow-100 px-4 py-4 sm:px-6">
                 <div class="text-sm">
-                    <a href="#" class="font-medium text-green-600 hover:text-green-500 transition-colors duration-150">
+                    <a href="#referrals-timeline-section" class="font-medium text-yellow-600 hover:text-yellow-500 transition-colors duration-150">
                         View referrals <span class="ml-1">&rarr;</span>
                     </a>
                 </div>
             </div>
         </div>
 
-        <!-- Today's Procedures -->
-        <div class="card bg-white overflow-hidden shadow-lg rounded-lg border-l-4 border-purple-500 animate-fade-in" style="animation-delay: 0.3s">
-            <div class="px-4 py-5 sm:p-6">
-                <div class="flex items-center">
-                    <div class="flex-shrink-0 bg-purple-100 rounded-full p-3">
-                        <i class="fas fa-procedures text-purple-600 text-xl"></i>
-                    </div>
-                    <div class="ml-5 w-0 flex-1">
-                        <dl>
-                            <dt class="text-sm font-medium text-gray-500 truncate">
-                                Today's Procedures
-                            </dt>
-                            <dd class="flex items-baseline">
-                                <div class="text-2xl font-semibold text-gray-900">
-                                    {{ stats.procedures_today }}
-                                </div>
-                                <div class="ml-2 flex items-baseline text-sm font-semibold text-yellow-600">
-                                    <i class="fas fa-minus"></i>
-                                    <span class="sr-only">No change</span>
-                                    0%
-                                </div>
-                            </dd>
-                        </dl>
-                    </div>
-                </div>
-            </div>
-            <div class="bg-gradient-to-r from-purple-50 to-purple-100 px-4 py-4 sm:px-6">
-                <div class="text-sm">
-                    <a href="#" class="font-medium text-purple-600 hover:text-purple-500 transition-colors duration-150">
-                        View schedule <span class="ml-1">&rarr;</span>
-                    </a>
-                </div>
-            </div>
-        </div>
-
-        <!-- Pending Reports -->
-        <div class="card bg-white overflow-hidden shadow-lg rounded-lg border-l-4 border-yellow-500 animate-fade-in" style="animation-delay: 0.4s">
+        <!-- Completed Reports - Grid column 2-3, Row 2 -->
+        <div class="card bg-white overflow-hidden shadow-lg rounded-lg border-l-4 border-green-500 animate-fade-in" style="animation-delay: 0.4s">
             <div class="px-4 py-5 sm:p-6">
                 <div class="flex items-center">
-                    <div class="flex-shrink-0 bg-yellow-100 rounded-full p-3">
-                        <i class="fas fa-file-medical-alt text-yellow-600 text-xl"></i>
+                    <div class="flex-shrink-0 bg-green-100 rounded-full p-3">
+                        <i class="fas fa-file-medical-alt text-green-600 text-xl"></i>
                     </div>
                     <div class="ml-5 w-0 flex-1">
                         <dl>
                             <dt class="text-sm font-medium text-gray-500 truncate">
-                                Pending Reports
+                                Completed Reports
                             </dt>
                             <dd class="flex items-baseline">
                                 <div class="text-2xl font-semibold text-gray-900">
-                                    {{ stats.pending_reports }}
+                                    {{ stats.completed_reports_today }}
                                 </div>
-                                <div class="ml-2 flex items-baseline text-sm font-semibold text-red-600">
-                                    <i class="fas fa-arrow-up"></i>
-                                    <span class="sr-only">Increased by</span>
-                                    8.2%
+                                <div class="ml-2 flex items-baseline text-sm font-semibold {{ 'text-green-600' if stats.reports_percent_change > 0 else 'text-red-600' if stats.reports_percent_change < 0 else 'text-gray-600' }}">
+                                    <i class="{{ 'fas fa-arrow-up' if stats.reports_percent_change > 0 else 'fas fa-arrow-down' if stats.reports_percent_change < 0 else 'fas fa-minus' }}"></i>
+                                    <span class="sr-only">{{ 'Increased by' if stats.reports_percent_change > 0 else 'Decreased by' if stats.reports_percent_change < 0 else 'No change' }}</span>
+                                    {{ '%.1f'|format(stats.reports_percent_change|abs) }}%
                                 </div>
                             </dd>
                         </dl>
                     </div>
                 </div>
             </div>
-            <div class="bg-gradient-to-r from-yellow-50 to-yellow-100 px-4 py-4 sm:px-6">
+            <div class="bg-gradient-to-r from-green-50 to-green-100 px-4 py-4 sm:px-6">
                 <div class="text-sm">
-                    <a href="{{ url_for('report.index') }}" class="font-medium text-yellow-600 hover:text-yellow-500 transition-colors duration-150">
+                    <a href="{{ url_for('report.index') }}" class="font-medium text-green-600 hover:text-green-500 transition-colors duration-150">
                         View reports <span class="ml-1">&rarr;</span>
                     </a>
                 </div>
@@ -308,7 +274,7 @@
         </div>
 
         <!-- HÀNG 4: Referrals Timeline (Full Width) -->
-        <div class="lg:col-span-3 bg-white shadow-lg overflow-hidden sm:rounded-lg animate-fade-in" style="animation-delay: 0.9s">
+        <div id="referrals-timeline-section" class="lg:col-span-3 bg-white shadow-lg overflow-hidden sm:rounded-lg animate-fade-in" style="animation-delay: 0.9s">
             <div class="px-4 py-5 sm:px-6 bg-gradient-to-r from-blue-50 to-white flex justify-between items-center">
                 <h2 class="text-lg font-medium text-gray-900">Referrals Timeline</h2>
                 <!-- Nút chọn Range -->
@@ -330,6 +296,7 @@
 {% endblock %}
 
 {% block scripts %}
+{{ super() }}
 <script>
     // Helper function to generate colors
     function generateColors(numColors) {
@@ -749,5 +716,19 @@
         }
 
     });
+
+    // Smooth scrolling for anchor links
+    document.querySelectorAll('a[href^="#"]').forEach(anchor => {
+        anchor.addEventListener('click', function (e) {
+            e.preventDefault();
+            const targetId = this.getAttribute('href').substring(1);
+            const targetElement = document.getElementById(targetId);
+            if (targetElement) {
+                targetElement.scrollIntoView({
+                    behavior: 'smooth'
+                });
+            }
+        });
+    });
 </script>
 {% endblock %}
diff --git a/app/templates/dietitian_dashboard.html b/app/templates/dietitian_dashboard.html
index f0fb766..0192e40 100644
--- a/app/templates/dietitian_dashboard.html
+++ b/app/templates/dietitian_dashboard.html
@@ -188,7 +188,7 @@
             const statusColorMap = {
                 'needs assessment': twColors.amber,
                 'in treatment': twColors.teal,
-                'completed': twColors.gray,
+                'completed': twColors.green,
                 'active': twColors.green,
                 'assessment in progress': twColors.blue
             };
diff --git a/app/templates/encounter_measurements.html b/app/templates/encounter_measurements.html
index 2fc04ae..c3ea3ec 100644
--- a/app/templates/encounter_measurements.html
+++ b/app/templates/encounter_measurements.html
@@ -54,35 +54,51 @@
             <div><strong>Start Time:</strong> {{ encounter.start_time.strftime('%d/%m/%Y %H:%M') if encounter.start_time else 'N/A' }}</div>
             {# Sửa cách hiển thị status #}
             <div><strong>Status:</strong> <span class="px-2 py-0.5 text-xs font-semibold rounded-full bg-{{ status.color if status and status.color else 'gray' }}-100 text-{{ status.color if status and status.color else 'gray' }}-800">{{ status.text if status and status.text else 'Unknown' }}</span></div>
+            {# Chỉ hiển thị Dietitian ở đây #}
             <div><strong>Dietitian:</strong> {{ encounter.assigned_dietitian.full_name if encounter.assigned_dietitian else 'None' }}</div>
-            {# Thêm hiển thị ML Intervention Status #}
-            <div class="md:col-span-3">
-                 <strong>ML Intervention Status:</strong>
-                 {% if encounter.needs_intervention is none %}
-                     {# Trường hợp chưa đánh giá #}
-                     <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
-                         <i class="fas fa-question-circle mr-1"></i> Not Evaluated
-                     </span>
-                 {% elif encounter.needs_intervention %}
-                     {# Trường hợp cần can thiệp #}
-                     <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
-                         <i class="fas fa-exclamation-triangle mr-1"></i> Needs Intervention
-                     </span>
-                 {% else %}
-                     {# Trường hợp không cần can thiệp #}
-                     <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
-                         <i class="fas fa-check-circle mr-1"></i> No Intervention Needed
-                     </span>
-                 {% endif %}
-                 {# Thêm nút Run ML Prediction ở đây #}
-                 <form action="{{ url_for('patients.run_encounter_ml', patient_id=patient.id, encounter_id=encounter.encounterID) }}" method="POST" class="inline-block ml-4">
-                    {# Sử dụng EmptyForm để tạo CSRF token nếu cần #}
+            
+            {# --- Dòng mới cho ML Status, Run Button, và Download Link --- #}
+            <div class="md:col-span-3 flex items-center space-x-4 mt-2"> {# Thêm mt-2 để tạo khoảng cách #}
+                 {# ML Intervention Status #}
+                 <div> {# Bọc status vào div để căn chỉnh #}
+                     <strong>ML Intervention Status:</strong>
+                     {% if encounter.ml_needs_intervention is none %} {# Dùng trường mới ml_needs_intervention #}
+                         {# Trường hợp chưa đánh giá #}
+                         <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
+                             <i class="fas fa-question-circle mr-1"></i> Not Evaluated
+                         </span>
+                     {% elif encounter.ml_needs_intervention %} {# Dùng trường mới #}
+                         {# Trường hợp cần can thiệp #}
+                         <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
+                             <i class="fas fa-exclamation-triangle mr-1"></i> Needs Intervention
+                         </span>
+                     {% else %}
+                         {# Trường hợp không cần can thiệp #}
+                         <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
+                             <i class="fas fa-check-circle mr-1"></i> No Intervention Needed
+                         </span>
+                     {% endif %}
+                 </div>
+                 {# Nút Run ML #}
+                 <form action="{{ url_for('patients.run_encounter_ml', patient_id=patient.id, encounter_id=encounter.encounterID) }}" method="POST" class="inline-block"> {# Bỏ ml-4 #}
                     {{ EmptyForm().csrf_token }}
                     <button type="submit" class="inline-flex items-center px-2.5 py-1 border border-transparent text-xs font-medium rounded shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-150 ease-in-out transform hover:scale-105" title="Run ML prediction based on latest measurement">
                         <i class="fas fa-brain mr-1"></i> Run ML
                     </button>
                  </form>
+                 {# Link Download ML Results #}
+                 {% if encounter.ml_needs_intervention is not none %} {# Dùng trường mới #}
+                    <div class="text-sm"> {# Bọc link vào div #}
+                        ML Results: 
+                        <a href="{{ url_for('patients.download_ml_results', patient_id=patient.id, encounter_id=encounter.encounterID) }}" 
+                           class="text-blue-600 hover:text-blue-800 hover:underline" 
+                           title="Download detailed ML prediction results as CSV">
+                            Download here
+                        </a>
+                    </div>
+                 {% endif %}
              </div>
+             {# --- Kết thúc dòng mới --- #}
         </div>
     </div>
 
diff --git a/app/templates/patient_detail.html b/app/templates/patient_detail.html
index efe24ce..830cb77 100644
--- a/app/templates/patient_detail.html
+++ b/app/templates/patient_detail.html
@@ -415,9 +415,42 @@
                                      <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
                                          {{ encounter.assigned_dietitian.full_name if encounter.assigned_dietitian else 'Chưa gán' }}
                                      </td>
-                                     <td class="px-6 py-4 whitespace-nowrap text-sm text-red-600">
-                                        {{ enc_data.unstable_metrics | join(', ') if enc_data.unstable_metrics else '-' }}
+                                     {# --- SỬA CỘT CHỈ SỐ BẤT ỔN --- #}
+                                     <td class="px-6 py-4 whitespace-nowrap text-sm text-red-600 font-medium">
+                                        {% set displayed_count = 0 %}
+                                        {# Ưu tiên hiển thị chỉ số vượt ngưỡng #}
+                                        {% if enc_data.limit_breaches %}
+                                            {% for feature in enc_data.limit_breaches %}
+                                                {% if displayed_count < 3 %}
+                                                    <span class="inline-block mr-1 px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-xs" title="Vượt ngưỡng giới hạn">
+                                                        <i class="fas fa-exclamation-circle mr-1"></i>{{ feature }}
+                                                    </span>
+                                                    {% set displayed_count = displayed_count + 1 %}
+                                                {% endif %}
+                                            {% endfor %}
+                                        {% endif %}
+                                        {# Hiển thị top features nếu còn chỗ #}
+                                        {% if displayed_count < 3 and enc_data.top_features %}
+                                            {% for feature in enc_data.top_features %}
+                                                {# Chỉ hiển thị nếu feature này chưa được hiển thị ở phần vượt ngưỡng #}
+                                                {% if feature not in enc_data.limit_breaches %}
+                                                    {% if displayed_count < 3 %}
+                                                        <span class="inline-block mr-1 px-1.5 py-0.5 bg-yellow-100 text-yellow-800 rounded text-xs" title="Ảnh hưởng cao nhất">
+                                                             <i class="fas fa-star mr-1"></i>{{ feature }}
+                                                         </span>
+                                                        {% set displayed_count = displayed_count + 1 %}
+                                                    {% endif %}
+                                                 {% endif %}
+                                            {% endfor %}
+                                        {% endif %}
+                                        {# Nếu không có gì để hiển thị #}
+                                        {% if displayed_count == 0 and enc_data.encounter.ml_needs_intervention is not none %}
+                                            <span class="text-gray-500 italic text-xs">None significant</span>
+                                        {% elif displayed_count == 0 %}
+                                            <span class="text-gray-400">-</span> {# Chưa chạy ML hoặc không có dữ liệu #}
+                                        {% endif %}
                                      </td>
+                                     {# --- KẾT THÚC SỬA --- #}
                                      <td class="px-6 py-4 whitespace-nowrap">
                                          <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-{{ status_info.color }}-100 text-{{ status_info.color }}-800">
                                              {{ status_info.text }}
@@ -616,8 +649,7 @@
                                                class="text-indigo-600 hover:text-indigo-900 text-lg p-1 rounded-md hover:bg-indigo-100 transition duration-150 ease-in-out"
                                                title="Edit Procedure">
                                                 <i class="fas fa-edit"></i>
-                                            </a>
-                                            {# Nút Delete (mở modal) #}
+                                            </a>{# Ensure no characters/whitespace here #}{# Nút Delete (mở modal) #}
                                             <button type="button"
                                                     class="text-red-600 hover:text-red-900 text-lg p-1 rounded-md hover:bg-red-100 transition duration-150 ease-in-out delete-procedure-btn"
                                                     title="Delete Procedure"
diff --git a/app/templates/patients.html b/app/templates/patients.html
index 71d5eed..5cd8513 100644
--- a/app/templates/patients.html
+++ b/app/templates/patients.html
@@ -27,11 +27,11 @@
             
             <div class="flex flex-col sm:flex-row gap-2">
                 <select id="status-filter" class="form-select rounded-md border-gray-300 py-2 pr-8 pl-3 text-base focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
-                    <option value="">All Statuses</option>
-                    <option value="NOT_ASSESSED">Not Assessed</option>
-                    <option value="NEEDS_ASSESSMENT">Needs Assessment</option>
-                    <option value="ASSESSMENT_IN_PROGRESS">Assessment In Progress</option>
-                    <option value="COMPLETED">Completed</option>
+                    <option value="" {% if not status %}selected{% endif %}>All Statuses</option>
+                    <option value="NOT_ASSESSED" {% if status == 'NOT_ASSESSED' %}selected{% endif %}>Not Assessed</option>
+                    <option value="NEEDS_ASSESSMENT" {% if status == 'NEEDS_ASSESSMENT' %}selected{% endif %}>Needs Assessment</option>
+                    <option value="ASSESSMENT_IN_PROGRESS" {% if status == 'ASSESSMENT_IN_PROGRESS' %}selected{% endif %}>Assessment In Progress</option>
+                    <option value="COMPLETED" {% if status == 'COMPLETED' %}selected{% endif %}>Completed</option>
                 </select>
                 
                 {# Nút Bulk Auto Assign (Admin only) #}
@@ -288,46 +288,61 @@
         
         const searchInput = document.getElementById('search');
         const statusFilter = document.getElementById('status-filter');
-        const patientTableBody = document.querySelector('tbody');
-        const patientRows = patientTableBody.querySelectorAll('tr');
-        const noPatientsRow = patientTableBody.querySelector('td[colspan="6"]')?.closest('tr'); // Hàng "No patients found"
-
-        function filterAndSearchPatients() {
-            const searchTerm = searchInput.value.toLowerCase();
-            const selectedStatus = statusFilter.value;
-            let visibleCount = 0;
-
-            patientRows.forEach(row => {
-                // Bỏ qua hàng "No patients found" khỏi việc lọc
-                if (row === noPatientsRow) return; 
-
-                const patientId = row.cells[0].textContent.toLowerCase();
-                const patientName = row.cells[1].textContent.toLowerCase();
-                const patientStatus = row.querySelector('[data-status]')?.getAttribute('data-status');
-
-                const matchesSearch = patientId.includes(searchTerm) || patientName.includes(searchTerm);
-                const matchesStatus = !selectedStatus || patientStatus === selectedStatus;
-
-                if (matchesSearch && matchesStatus) {
-                    row.style.display = ''; // Hiện hàng
-                    visibleCount++;
+        
+        // SỬA: Thay đổi xử lý sự kiện cho bộ lọc trạng thái
+        if (statusFilter) {
+            statusFilter.addEventListener('change', function() {
+                const selectedStatus = this.value;
+                // Tạo URL mới với tham số status
+                let newUrl = new URL(window.location.href);
+                
+                // Cập nhật hoặc thêm tham số status vào URL
+                if (selectedStatus) {
+                    newUrl.searchParams.set('status', selectedStatus);
                 } else {
-                    row.style.display = 'none'; // Ẩn hàng
+                    newUrl.searchParams.delete('status');
+                }
+                
+                // Đặt lại trang về 1 khi thay đổi bộ lọc
+                newUrl.searchParams.set('page', '1');
+                
+                // Giữ lại tham số tìm kiếm nếu có
+                const searchTerm = searchInput.value.trim();
+                if (searchTerm) {
+                    newUrl.searchParams.set('search', searchTerm);
                 }
+                
+                // Chuyển hướng đến URL mới
+                window.location.href = newUrl.toString();
             });
-
-            // Hiển thị/Ẩn thông báo "No patients found"
-            if (noPatientsRow) {
-                noPatientsRow.style.display = visibleCount === 0 ? '' : 'none';
+        }
+        
+        // SỬA: Cập nhật xử lý tìm kiếm để sử dụng server-side
+        if (searchInput) {
+            // Tạo form ảo để xử lý tìm kiếm khi nhấn Enter
+            const searchForm = document.createElement('form');
+            searchForm.method = 'GET';
+            searchForm.action = window.location.pathname;
+            searchInput.parentNode.appendChild(searchForm);
+            searchForm.appendChild(searchInput);
+            
+            // Thêm input ẩn để giữ lại trạng thái bộ lọc
+            if (statusFilter.value) {
+                const statusInput = document.createElement('input');
+                statusInput.type = 'hidden';
+                statusInput.name = 'status';
+                statusInput.value = statusFilter.value;
+                searchForm.appendChild(statusInput);
             }
             
-            // Cập nhật thông tin pagination (Tạm thời bỏ qua, cần logic phức tạp hơn nếu phân trang phía server)
-            // Bạn có thể cần gọi lại API hoặc cập nhật logic phân trang JS nếu muốn phân trang hoạt động chính xác với bộ lọc/tìm kiếm client-side
+            // Xử lý sự kiện submit form
+            searchForm.addEventListener('submit', function(e) {
+                const searchTerm = searchInput.value.trim();
+                if (!searchTerm) {
+                    e.preventDefault();
+                }
+            });
         }
-
-        // Event listeners
-        searchInput.addEventListener('input', filterAndSearchPatients);
-        statusFilter.addEventListener('change', filterAndSearchPatients);
         
         // Delete modal functionality (giữ nguyên)
         const deleteButtons = document.querySelectorAll('.delete-patient');
diff --git a/app/templates/support.html b/app/templates/support.html
index 5b7ca12..83b58a9 100644
--- a/app/templates/support.html
+++ b/app/templates/support.html
@@ -30,7 +30,7 @@
         </div>
 
         <!-- Message Input Form -->
-        <div class="p-6 bg-white border-t border-gray-200">
+        <div id="chat-input-section" class="p-6 bg-white border-t border-gray-200">
             <form method="POST" action="{{ url_for('.index') }}" class="flex items-center">
                 {{ form.hidden_tag() }}
                 <div class="flex-grow mr-4">
@@ -54,11 +54,51 @@
 {{ super() }}
 <script>
 document.addEventListener('DOMContentLoaded', function() {
-    // Scroll to the bottom of the message list on page load
     const messageList = document.getElementById('message-list');
+    const chatInputSection = document.getElementById('chat-input-section');
+
+    // 1. Scroll message list to bottom initially
     if (messageList) {
         messageList.scrollTop = messageList.scrollHeight;
     }
+
+    // 2. Handle smooth scrolling for internal anchor links (if any)
+    document.querySelectorAll('a[href^="#"]').forEach(anchor => {
+        anchor.addEventListener('click', function (e) {
+            e.preventDefault();
+            const targetId = this.getAttribute('href').substring(1);
+            const targetElement = document.getElementById(targetId);
+            if (targetElement) {
+                targetElement.scrollIntoView({
+                    behavior: 'smooth'
+                });
+                 // Optionally update hash without triggering scroll
+                 // history.pushState(null, null, `#${targetId}`);
+            }
+        });
+    });
+
+    // 3. Check URL hash on page load and scroll smoothly from top if needed
+    if (window.location.hash === '#chat-input-section' && chatInputSection) {
+        // Scroll from the top to the target section
+        // Using requestAnimationFrame ensures the browser is ready to paint the scroll
+        requestAnimationFrame(() => {
+            window.scrollTo({ top: 0, behavior: 'instant' }); // Ensure we start from the very top instantly
+            requestAnimationFrame(() => { // Second frame for the smooth scroll to start
+                chatInputSection.scrollIntoView({
+                    behavior: 'smooth'
+                });
+            });
+        });
+        
+        // Optional: Remove the hash from the URL after scrolling
+        if (history.replaceState) {
+            // Wait a bit for the scroll to likely finish before removing hash
+            setTimeout(() => {
+                history.replaceState(null, null, window.location.pathname + window.location.search);
+            }, 1000); // Adjust delay if needed
+        }
+    }
 });
 </script>
 {% endblock %} 
\ No newline at end of file
diff --git a/migrations/versions/fb934f468320_add_ml_pred.py b/migrations/versions/fb934f468320_add_ml_pred.py
new file mode 100644
index 0000000..7404e7c
--- /dev/null
+++ b/migrations/versions/fb934f468320_add_ml_pred.py
@@ -0,0 +1,50 @@
+"""add ml pred
+
+Revision ID: fb934f468320
+Revises: fe0cbf650625
+Create Date: 2025-04-22 01:02:07.781555
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+
+# revision identifiers, used by Alembic.
+revision = 'fb934f468320'
+down_revision = 'fe0cbf650625'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    with op.batch_alter_table('encounters', schema=None) as batch_op:
+        batch_op.add_column(sa.Column('ml_needs_intervention', sa.Boolean(), nullable=True, comment='Result of ML prediction (True=Needs Intervention)'))
+        batch_op.add_column(sa.Column('ml_prediction_time', sa.DateTime(), nullable=True, comment='Timestamp of the last ML prediction run'))
+        batch_op.add_column(sa.Column('ml_top_features_json', sa.JSON(), nullable=True, comment='JSON array of top influential features from SHAP'))
+        batch_op.add_column(sa.Column('ml_limit_breaches_json', sa.JSON(), nullable=True, comment='JSON array of features breaching physiological limits'))
+
+    with op.batch_alter_table('uploadedfiles', schema=None) as batch_op:
+        batch_op.alter_column('error_details',
+               existing_type=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'),
+               type_=sa.Text(length=16777215),
+               existing_nullable=True)
+
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    with op.batch_alter_table('uploadedfiles', schema=None) as batch_op:
+        batch_op.alter_column('error_details',
+               existing_type=sa.Text(length=16777215),
+               type_=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'),
+               existing_nullable=True)
+
+    with op.batch_alter_table('encounters', schema=None) as batch_op:
+        batch_op.drop_column('ml_limit_breaches_json')
+        batch_op.drop_column('ml_top_features_json')
+        batch_op.drop_column('ml_prediction_time')
+        batch_op.drop_column('ml_needs_intervention')
+
+    # ### end Alembic commands ###
diff --git a/model/train.py b/model/train.py
index d23c381..9543a9d 100644
--- a/model/train.py
+++ b/model/train.py
@@ -1,18 +1,30 @@
 import pandas as pd
 import pickle
+import os
+import json
 from sklearn.ensemble import RandomForestClassifier
 from sklearn.model_selection import train_test_split
 from sklearn.preprocessing import StandardScaler
 from sklearn.impute import SimpleImputer
 from sklearn.metrics import classification_report, confusion_matrix
 
+# --- Configuration ---
+DATA_PATH = 'model/data/train_data.csv'
+MODEL_DIR = 'model/train_model'
+MODEL_FILENAME = 'referral_model.pkl'
+IMPUTER_FILENAME = 'imputer.pkl'
+SCALER_FILENAME = 'scaler.pkl'
+FEATURES_FILENAME = 'feature_columns.json'
+
+# --- Load Data ---
 # Load the dataset
 try:
-    data = pd.read_csv('model/data/train_data.csv')
-    print(f"Loaded training data with {len(data)} rows and {len(data.columns)} columns.")
+    data = pd.read_csv(DATA_PATH)
+    print(f"Loaded training data from {DATA_PATH} with {len(data)} rows and {len(data.columns)} columns.")
 except FileNotFoundError:
-    raise FileNotFoundError("Training data file 'train_data.csv' not found. Please run generate_train_data.py to create it.")
+    raise FileNotFoundError(f"Training data file '{DATA_PATH}' not found. Please run generate_train_data.py to create it.")
 
+# --- Feature Engineering & Preprocessing ---
 # Define feature columns (excluding identifier columns and target)
 feature_columns = [
     'temperature', 'heart_rate', 'blood_pressure_systolic', 'blood_pressure_diastolic',
@@ -21,11 +33,12 @@ feature_columns = [
     'tidal_vol_actual', 'tidal_vol_kg', 'tidal_vol_spon', 'bmi'
 ]
 
-# Drop non-feature columns
-columns_to_drop = ['patientID', 'encounterID', 'measurementDateTime', 'referral']
-data = data[feature_columns + ['referral']]  # Keep only feature columns and target
+# Select features and target
+# Drop non-feature columns - Sicherstellen, dass nur die Feature-Spalten und das Ziel vorhanden sind
+if 'referral' not in data.columns:
+    raise ValueError("Target column 'referral' not found in the dataset.")
 
-# Separate features and target
+data = data[feature_columns + ['referral']]  # Keep only feature columns and target
 X = data[feature_columns]
 y = data['referral']
 
@@ -37,6 +50,7 @@ X_imputed = imputer.fit_transform(X)
 scaler = StandardScaler()
 X_scaled = scaler.fit_transform(X_imputed)
 
+# --- Train Model ---
 # Split the data into training and testing sets (80% train, 20% test)
 X_train, X_test, y_train, y_test = train_test_split(X_scaled, y, test_size=0.2, random_state=42)
 
@@ -44,21 +58,35 @@ X_train, X_test, y_train, y_test = train_test_split(X_scaled, y, test_size=0.2,
 model = RandomForestClassifier(n_estimators=100, random_state=42)
 model.fit(X_train, y_train)
 
+# --- Evaluate Model ---
 # Evaluate the model on the test set
 y_pred = model.predict(X_test)
-print("Classification Report:")
+print("\nClassification Report:")
 print(classification_report(y_test, y_pred))
-print("Confusion Matrix:")
+print("\nConfusion Matrix:")
 print(confusion_matrix(y_test, y_pred))
 
-# Save the trained model, imputer, scaler, and feature columns for later use
-with open('model/train_model/referral_model.pkl', 'wb') as f:
-    pickle.dump(model, f)
-with open('model/train_model/imputer.pkl', 'wb') as f:
-    pickle.dump(imputer, f)
-with open('model/train_model/scaler.pkl', 'wb') as f:
-    pickle.dump(scaler, f)
-with open('model/train_model/feature_columns.pkl', 'wb') as f:
-    pickle.dump(feature_columns, f)
-
-print("Model, imputer, scaler, and feature columns saved successfully.")
\ No newline at end of file
+# --- Save Components ---
+# Ensure the model directory exists
+os.makedirs(MODEL_DIR, exist_ok=True)
+
+# Save the trained model, imputer, scaler
+model_path = os.path.join(MODEL_DIR, MODEL_FILENAME)
+imputer_path = os.path.join(MODEL_DIR, IMPUTER_FILENAME)
+scaler_path = os.path.join(MODEL_DIR, SCALER_FILENAME)
+features_path = os.path.join(MODEL_DIR, FEATURES_FILENAME)
+
+with open(model_path, 'wb') as f: pickle.dump(model, f)
+with open(imputer_path, 'wb') as f: pickle.dump(imputer, f)
+with open(scaler_path, 'wb') as f: pickle.dump(scaler, f)
+
+# *** SỬA: Lưu feature_columns vào file JSON ***
+with open(features_path, 'w') as f:
+    json.dump(feature_columns, f)
+
+print(f"\nModel saved to {model_path}")
+print(f"Imputer saved to {imputer_path}")
+print(f"Scaler saved to {scaler_path}")
+print(f"Feature columns saved to {features_path}")
+
+print("\nTraining and saving process completed successfully.")
\ No newline at end of file
diff --git a/model/train_model/imputer.pkl b/model/train_model/imputer.pkl
index 819015ad3ac4e3c019f8f46344d8b5b4606580ad..12e64feb1e60697d8dd527f9a8970c9e408ea5f4 100644
GIT binary patch
delta 15
WcmZ3@zM6f*5@vQ2J%cHwNqPV&)dcPU

delta 15
WcmZ3@zM6f*5@vQYJ;N!bNqPV&=mhZq

diff --git a/model/train_model/referral_model.pkl b/model/train_model/referral_model.pkl
index 8091582a3f1de877cd4c8e50103314fe9fac4d52..a851eb360f054280fadaf1ace6284f23af5b4cd2 100644
GIT binary patch
delta 3647
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
BPB;Jn

delta 3647
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
BPCEbq

diff --git a/model/train_model/scaler.pkl b/model/train_model/scaler.pkl
index 6039565272b7d0b1a2e4fd02e282949d233f5c2a..4d2e52a1ef0712034d89712cf0ff48819c861af0 100644
GIT binary patch
delta 15
WcmdnVzLR~!CT4aMJ%cHwNqPV)=>-@7

delta 15
WcmdnVzLR~!CT4asJ;N!bNqPV)`~@2T

diff --git a/requirements.txt b/requirements.txt
index 79b4a88..9fe069e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -47,4 +47,5 @@ flake8==6.0.0
 isort==5.12.0
 mypy==0.991 
 Faker
-timeago
\ No newline at end of file
+timeago
+shap
\ No newline at end of file
-- 
GitLab