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">© {{ current_year|default(2023) }} CCU HTM - Intensive Care Unit Nutrition Management System</p> + <p class="text-center text-gray-500 text-sm">© {{ 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">→</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">→</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">→</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">→</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